From 4d30d9203e1690e673b407af892e85f8ad5532c2 Mon Sep 17 00:00:00 2001 From: mbnuqw Date: Sun, 3 Mar 2024 19:55:03 +0500 Subject: [PATCH] feat: sort tabs by title, url, access time - tab context menu options - tab panel context menu options - keybindings (#170, #643) --- src/_locales/dict.browser.json | 36 +++ src/_locales/dict.common.ts | 111 ++++++++++ src/_locales/dict.setup-page.ts | 3 + src/_locales/dict.sidebar.ts | 7 + src/defaults/menu.ts | 30 +++ src/manifest.json | 36 +++ src/page.setup/components/keybindings.vue | 16 ++ src/page.setup/components/menu-editor.vue | 18 ++ src/services/keybindings.actions.ts | 29 +++ src/services/menu.options.tabs.ts | 223 +++++++++++++++++++ src/services/tabs.fg.sorting.ts | 257 ++++++++++++++++++++++ 11 files changed, 766 insertions(+) create mode 100644 src/services/tabs.fg.sorting.ts diff --git a/src/_locales/dict.browser.json b/src/_locales/dict.browser.json index 527d87eda..bf2db4b26 100644 --- a/src/_locales/dict.browser.json +++ b/src/_locales/dict.browser.json @@ -700,6 +700,42 @@ "zh_CN": "将选定(或活动)标签页移动到第十个面板", "zh_TW": "將選定(或當前)分頁移動到第十個面板" }, + "KbSortTabsByTitleAsc": { + "en": "Sort selected tabs or siblings of the active tab by title (A-z)" + }, + "KbSortTabsByTitleDes": { + "en": "Sort selected tabs or siblings of the active tab by title (z-A)" + }, + "KbSortTabsByTrlAsc": { + "en": "Sort selected tabs or siblings of the active tab by URL (A-z)" + }, + "KbSortTabsByTrlDes": { + "en": "Sort selected tabs or siblings of the active tab by URL (z-A)" + }, + "KbSortTabsByTimeAsc": { + "en": "Sort selected tabs or siblings of the active tab by access time (Old-Recent)" + }, + "KbSortTabsByTimeDes": { + "en": "Sort selected tabs or siblings of the active tab by access time (Recent-Old)" + }, + "KbSortPanelTabsByTitleAsc": { + "en": "Sort tabs of the active panel by title (A-z)" + }, + "KbSortPanelTabsByTitleDes": { + "en": "Sort tabs of the active panel by title (z-A)" + }, + "KbSortPanelTabsByTrlAsc": { + "en": "Sort tabs of the active panel by URL (A-z)" + }, + "KbSortPanelTabsByTrlDes": { + "en": "Sort tabs of the active panel by URL (z-A)" + }, + "KbSortPanelTabsByTimeAsc": { + "en": "Sort tabs of the active panel by access time (Old-Recent)" + }, + "KbSortPanelTabsByTimeDes": { + "en": "Sort tabs of the active panel by access time (Recent-Old)" + }, "proxy_popup_title_prefix": { "en": "Proxy for \"", diff --git a/src/_locales/dict.common.ts b/src/_locales/dict.common.ts index edca2737f..b01b346c1 100644 --- a/src/_locales/dict.common.ts +++ b/src/_locales/dict.common.ts @@ -1114,6 +1114,96 @@ export const commonTranslations: Translations = { zh_CN: '关闭其他标签页', zh_TW: '關閉其他分頁', }, + 'menu.tab.sort_sub_menu_name': { + en: 'Sort', + ru: 'Сортировать', + de: 'Sortieren', + zh: '排序', + }, + 'menu.tab.sort_by_title_asc': { + en: 'Sort by title (A-z)', + ru: 'Сортировать по названию (А-я)', + de: 'Sortieren nach Titel (A-z)', + zh_CN: '按名称排序 (A-z)', + zh_TW: '依名稱排序 (A-z)', + }, + 'menu.tab.sort_by_title_des': { + en: 'Sort by title (z-A)', + ru: 'Сортировать по названию (я-А)', + de: 'Sortieren nach Titel (z-A)', + zh_CN: '按名称排序 (z-A)', + zh_TW: '依名稱排序 (z-A)', + }, + 'menu.tab.sort_by_url_asc': { + en: 'Sort by URL (A-z)', + ru: 'Сортировать по адресу (А-я)', + de: 'Sortieren nach URL (A-z)', + zh_CN: '按网址排序 (A-z)', + zh_TW: '依網址排序 (A-z)', + }, + 'menu.tab.sort_by_url_des': { + en: 'Sort by URL (z-A)', + ru: 'Сортировать по адресу (я-А)', + de: 'Sortieren nach URL (z-A)', + zh_CN: '按网址排序 (z-A)', + zh_TW: '依網址排序 (z-A)', + }, + 'menu.tab.sort_by_time_asc': { + en: 'Sort by access time (Old-Recent)', + ru: 'Сортировать по времени доступа (Старые-Новые)', + de: 'Sortieren nach Zugriffszeit (Alt-Neu)', + zh_CN: '按存取时间排序(旧-新)', + zh_TW: '按訪問時間排序(舊-新)', + }, + 'menu.tab.sort_by_time_des': { + en: 'Sort by access time (Recent-Old)', + ru: 'Сортировать по времени доступа (Новые-Старые)', + de: 'Sortieren nach Zugriffszeit (Neu-Alt)', + zh_CN: '按存取时间排序(新-旧)', + zh_TW: '按訪問時間排序(新-舊)', + }, + 'menu.tab.sort_tree_by_title_asc': { + en: 'Sort tree by title (A-z)', + ru: 'Сортировать дерево по названию (А-я)', + de: 'Baum nach Titel sortieren (A-z)', + zh_CN: '按标题对树进行排序 (A-z)', + zh_TW: '按標題對樹進行排序 (A-z)', + }, + 'menu.tab.sort_tree_by_title_des': { + en: 'Sort tree by title (z-A)', + ru: 'Сортировать дерево по названию (я-А)', + de: 'Baum nach Titel sortieren (z-A)', + zh_CN: '按标题对树进行排序 (z-A)', + zh_TW: '按標題對樹進行排序 (z-A)', + }, + 'menu.tab.sort_tree_by_url_asc': { + en: 'Sort tree by URL (A-z)', + ru: 'Сортировать дерево по адресу (А-я)', + de: 'Baum nach URL sortieren (A-z)', + zh_CN: '按 URL 对树排序 (A-z)', + zh_TW: '按 URL 對樹排序 (A-z)', + }, + 'menu.tab.sort_tree_by_url_des': { + en: 'Sort tree by URL (z-A)', + ru: 'Сортировать дерево по адресу (я-А)', + de: 'Baum nach URL sortieren (z-A)', + zh_CN: '按 URL 对树排序 (z-A)', + zh_TW: '按 URL 對樹排序 (z-A)', + }, + 'menu.tab.sort_tree_by_time_asc': { + en: 'Sort tree by access time (Old-Recent)', + ru: 'Сортировать дерево по времени доступа (Старые-Новые)', + de: 'Baum nach Zugriffszeit sortieren (Alt-Neu)', + zh_CN: '按访问时间排序树(旧-新)', + zh_TW: '按訪問時間對樹進行排序(舊-新)', + }, + 'menu.tab.sort_tree_by_time_des': { + en: 'Sort tree by access time (Recent-Old)', + ru: 'Сортировать дерево по времени доступа (Новые-Старые)', + de: 'Baum nach Zugriffszeit sortieren (Neu-Alt)', + zh_CN: '按访问时间排序树(新-旧)', + zh_TW: '按訪問時間對樹進行排序(新-舊)', + }, // - Tabs panel 'menu.tabs_panel.mute_all_audible': { en: 'Mute all audible tabs', @@ -1191,6 +1281,27 @@ export const commonTranslations: Translations = { de: 'Panel entfernen', zh: '移除面板', }, + 'menu.tabs_panel.sort_all_sub_menu_name': { + en: 'Sort all tabs', + }, + 'menu.tabs_panel.sort_all_by_title_asc': { + en: 'Sort all tabs by title (A-z)', + }, + 'menu.tabs_panel.sort_all_by_title_des': { + en: 'Sort all tabs by title (z-A)', + }, + 'menu.tabs_panel.sort_all_by_url_asc': { + en: 'Sort all tabs by URL (A-z)', + }, + 'menu.tabs_panel.sort_all_by_url_des': { + en: 'Sort all tabs by URL (z-A)', + }, + 'menu.tabs_panel.sort_all_by_time_asc': { + en: 'Sort all tabs by access time (Old-Recent)', + }, + 'menu.tabs_panel.sort_all_by_time_des': { + en: 'Sort all tabs by access time (Recent-Old)', + }, // - History 'menu.history.open': { en: 'Open', diff --git a/src/_locales/dict.setup-page.ts b/src/_locales/dict.setup-page.ts index 3527fc396..f9d8161c1 100644 --- a/src/_locales/dict.setup-page.ts +++ b/src/_locales/dict.setup-page.ts @@ -4025,6 +4025,9 @@ Beispiele: "*", "ctrl+$", "ctrl+alt+g"`, zh_CN: '移动标签', zh_TW: '移動分頁', }, + 'settings.kb_sort_tabs': { + en: 'Sorting tabs', + }, 'settings.reset_kb': { en: 'Reset Keybindings', ru: 'Сбросить клав. настройки', diff --git a/src/_locales/dict.sidebar.ts b/src/_locales/dict.sidebar.ts index 265734daa..169006718 100644 --- a/src/_locales/dict.sidebar.ts +++ b/src/_locales/dict.sidebar.ts @@ -526,6 +526,13 @@ export const sidebarTranslations: Translations = { zh_CN: '书签排序', zh_TW: '書籤排序', }, + 'notif.tabs_sort': { + en: 'Sorting tabs...', + ru: 'Сортировка вкладок...', + de: 'Sortiere Tabs...', + zh_CN: '排序选项卡...', + zh_TW: '排序選項卡...', + }, 'notif.snapshot_created': { en: 'Snapshot created', ru: 'Снепшот создан', diff --git a/src/defaults/menu.ts b/src/defaults/menu.ts index f370d9ef2..d5d4ebbf7 100644 --- a/src/defaults/menu.ts +++ b/src/defaults/menu.ts @@ -15,6 +15,24 @@ export const TABS_MENU: MenuConf = [ name: '%menu.tab.colorize_', opts: ['colorizeTab'], }, + { + name: '%menu.tab.sort_sub_menu_name', + opts: [ + 'sortTabsByTitleAscending', + 'sortTabsByTitleDescending', + 'sortTabsByUrlAscending', + 'sortTabsByUrlDescending', + 'sortTabsByAccessTimeAscending', + 'sortTabsByAccessTimeDescending', + 'separator-45654', + 'sortTabsTreeByTitleAscending', + 'sortTabsTreeByTitleDescending', + 'sortTabsTreeByUrlAscending', + 'sortTabsTreeByUrlDescending', + 'sortTabsTreeByAccessTimeAscending', + 'sortTabsTreeByAccessTimeDescending', + ], + }, 'separator-2', 'pin', 'duplicate', @@ -33,6 +51,18 @@ export const TABS_MENU: MenuConf = [ export const TABS_PANEL_MENU: MenuConf = [ { opts: ['undoRmTab', 'muteAllAudibleTabs', 'reloadTabs', 'discardTabs'] }, + 'separator-1224', + { + name: '%menu.tabs_panel.sort_all_sub_menu_name', + opts: [ + 'sortAllTabsByTitleAscending', + 'sortAllTabsByTitleDescending', + 'sortAllTabsByUrlAscending', + 'sortAllTabsByUrlDescending', + 'sortAllTabsByAccessTimeAscending', + 'sortAllTabsByAccessTimeDescending', + ], + }, 'separator-7', 'selectAllTabs', 'collapseInactiveBranches', diff --git a/src/manifest.json b/src/manifest.json index 9a2b74854..0d85d9975 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -386,6 +386,42 @@ }, "sel_child_tabs": { "description": "__MSG_KbSelChildTabs__" + }, + "sort_tabs_by_title_asc": { + "description": "__MSG_KbSortTabsByTitleAsc__" + }, + "sort_tabs_by_title_des": { + "description": "__MSG_KbSortTabsByTitleDes__" + }, + "sort_tabs_by_url_asc": { + "description": "__MSG_KbSortTabsByTrlAsc__" + }, + "sort_tabs_by_url_des": { + "description": "__MSG_KbSortTabsByTrlDes__" + }, + "sort_tabs_by_time_asc": { + "description": "__MSG_KbSortTabsByTimeAsc__" + }, + "sort_tabs_by_time_des": { + "description": "__MSG_KbSortTabsByTimeDes__" + }, + "sort_panel_tabs_by_title_asc": { + "description": "__MSG_KbSortPanelTabsByTitleAsc__" + }, + "sort_panel_tabs_by_title_des": { + "description": "__MSG_KbSortPanelTabsByTitleDes__" + }, + "sort_panel_tabs_by_url_asc": { + "description": "__MSG_KbSortPanelTabsByTrlAsc__" + }, + "sort_panel_tabs_by_url_des": { + "description": "__MSG_KbSortPanelTabsByTrlDes__" + }, + "sort_panel_tabs_by_time_asc": { + "description": "__MSG_KbSortPanelTabsByTimeAsc__" + }, + "sort_panel_tabs_by_time_des": { + "description": "__MSG_KbSortPanelTabsByTimeDes__" } }, "browser_action": { diff --git a/src/page.setup/components/keybindings.vue b/src/page.setup/components/keybindings.vue index 5075e5cfa..4f58655d5 100644 --- a/src/page.setup/components/keybindings.vue +++ b/src/page.setup/components/keybindings.vue @@ -157,6 +157,22 @@ KeybindingField(:keybinding="Keybindings.reactive.byName.move_tabs_to_panel_8") KeybindingField(:keybinding="Keybindings.reactive.byName.move_tabs_to_panel_9") + section + h2 {{translate('settings.kb_sort_tabs')}} + span.header-shadow + KeybindingField.-no-separator(:keybinding="Keybindings.reactive.byName.sort_tabs_by_title_asc") + KeybindingField(:keybinding="Keybindings.reactive.byName.sort_tabs_by_title_des") + KeybindingField(:keybinding="Keybindings.reactive.byName.sort_tabs_by_url_asc") + KeybindingField(:keybinding="Keybindings.reactive.byName.sort_tabs_by_url_des") + KeybindingField(:keybinding="Keybindings.reactive.byName.sort_tabs_by_time_asc") + KeybindingField(:keybinding="Keybindings.reactive.byName.sort_tabs_by_time_des") + KeybindingField(:keybinding="Keybindings.reactive.byName.sort_panel_tabs_by_title_asc") + KeybindingField(:keybinding="Keybindings.reactive.byName.sort_panel_tabs_by_title_des") + KeybindingField(:keybinding="Keybindings.reactive.byName.sort_panel_tabs_by_url_asc") + KeybindingField(:keybinding="Keybindings.reactive.byName.sort_panel_tabs_by_url_des") + KeybindingField(:keybinding="Keybindings.reactive.byName.sort_panel_tabs_by_time_asc") + KeybindingField(:keybinding="Keybindings.reactive.byName.sort_panel_tabs_by_time_des") + section .ctrls .btn(@click="Keybindings.resetKeybindings") {{translate('settings.reset_kb')}} diff --git a/src/page.setup/components/menu-editor.vue b/src/page.setup/components/menu-editor.vue index eb7c85412..86660a030 100644 --- a/src/page.setup/components/menu-editor.vue +++ b/src/page.setup/components/menu-editor.vue @@ -182,6 +182,18 @@ const TABS_MENU_OPTS: Record = { group: 'menu.tab.group', flatten: 'menu.tab.flatten', urlConf: 'menu.tab.url_conf', + sortTabsByTitleAscending: 'menu.tab.sort_by_title_asc', + sortTabsByTitleDescending: 'menu.tab.sort_by_title_des', + sortTabsByUrlAscending: 'menu.tab.sort_by_url_asc', + sortTabsByUrlDescending: 'menu.tab.sort_by_url_des', + sortTabsByAccessTimeAscending: 'menu.tab.sort_by_time_asc', + sortTabsByAccessTimeDescending: 'menu.tab.sort_by_time_des', + sortTabsTreeByTitleAscending: 'menu.tab.sort_tree_by_title_asc', + sortTabsTreeByTitleDescending: 'menu.tab.sort_tree_by_title_des', + sortTabsTreeByUrlAscending: 'menu.tab.sort_tree_by_url_asc', + sortTabsTreeByUrlDescending: 'menu.tab.sort_tree_by_url_des', + sortTabsTreeByAccessTimeAscending: 'menu.tab.sort_tree_by_time_asc', + sortTabsTreeByAccessTimeDescending: 'menu.tab.sort_tree_by_time_des', clearCookies: 'menu.tab.clear_cookies', closeDescendants: 'menu.tab.close_descendants', close: 'menu.tab.close', @@ -206,6 +218,12 @@ const TABS_PANEL_MENU_OPTS: Record = { bookmarkTabsPanel: 'menu.tabs_panel.bookmark', restoreFromBookmarks: 'menu.tabs_panel.restore_from_bookmarks', convertToBookmarksPanel: 'menu.tabs_panel.convert_to_bookmarks_panel', + sortAllTabsByTitleAscending: 'menu.tabs_panel.sort_all_by_title_asc', + sortAllTabsByTitleDescending: 'menu.tabs_panel.sort_all_by_title_des', + sortAllTabsByUrlAscending: 'menu.tabs_panel.sort_all_by_url_asc', + sortAllTabsByUrlDescending: 'menu.tabs_panel.sort_all_by_url_des', + sortAllTabsByAccessTimeAscending: 'menu.tabs_panel.sort_all_by_time_asc', + sortAllTabsByAccessTimeDescending: 'menu.tabs_panel.sort_all_by_time_des', } const BOOKMARKS_MENU_OPTS: Record = { diff --git a/src/services/keybindings.actions.ts b/src/services/keybindings.actions.ts index f2cde2d7d..836a49741 100644 --- a/src/services/keybindings.actions.ts +++ b/src/services/keybindings.actions.ts @@ -16,6 +16,7 @@ import { Store } from 'src/services/storage' import { SwitchingTabScope } from './tabs.fg.actions' import * as IPC from 'src/services/ipc' import * as Logs from 'src/services/logs' +import { By as SortBy, sort as sortTabs } from 'src/services/tabs.fg.sorting' import { SetupPage } from './setup-page' const VALID_SHORTCUT = @@ -196,6 +197,34 @@ function onCmd(name: string): void { else if (name === 'group_tabs_act') onKeyGroupTabs(true) else if (name === 'flatten_tabs') onKeyFlattenTabs() else if (name === 'sel_child_tabs') onKeySelChildTabs() + else if (name === 'sort_tabs_by_title_asc') onKeySortTabs(SortBy.Title, 1) + else if (name === 'sort_tabs_by_title_des') onKeySortTabs(SortBy.Title, -1) + else if (name === 'sort_tabs_by_url_asc') onKeySortTabs(SortBy.Url, 1) + else if (name === 'sort_tabs_by_url_des') onKeySortTabs(SortBy.Url, -1) + else if (name === 'sort_tabs_by_time_asc') onKeySortTabs(SortBy.ATime, 1) + else if (name === 'sort_tabs_by_time_des') onKeySortTabs(SortBy.ATime, -1) + else if (name === 'sort_panel_tabs_by_title_asc') onKeySortTabs(SortBy.Title, 1, true, true) + else if (name === 'sort_panel_tabs_by_title_des') onKeySortTabs(SortBy.Title, -1, true, true) + else if (name === 'sort_panel_tabs_by_url_asc') onKeySortTabs(SortBy.Url, 1, true, true) + else if (name === 'sort_panel_tabs_by_url_des') onKeySortTabs(SortBy.Url, -1, true, true) + else if (name === 'sort_panel_tabs_by_time_asc') onKeySortTabs(SortBy.ATime, 1, true, true) + else if (name === 'sort_panel_tabs_by_time_des') onKeySortTabs(SortBy.ATime, -1, true, true) +} + +function onKeySortTabs(type: SortBy, dir = 0, tree?: boolean, panel?: boolean) { + if (Tabs.sorting) return + let ids + + if (panel) { + const activePanel = Sidebar.panelsById[Sidebar.activePanelId] + if (!Utils.isTabsPanel(activePanel)) return + ids = activePanel.tabs.map(t => t.id) + } else { + ids = Selection.isTabs() ? Selection.get() : [Tabs.activeId] + } + if (!ids.length) return + + sortTabs(type, ids, dir, tree) } function onKeyScrollToTopBottom(dir: 1 | -1) { diff --git a/src/services/menu.options.tabs.ts b/src/services/menu.options.tabs.ts index 0d195bf05..1d570a9f2 100644 --- a/src/services/menu.options.tabs.ts +++ b/src/services/menu.options.tabs.ts @@ -13,6 +13,7 @@ import { Containers } from 'src/services/containers' import { ItemInfo } from 'src/types/tabs' import * as Logs from './logs' import * as Popups from 'src/services/popups' +import * as TabsSorting from 'src/services/tabs.fg.sorting' export const tabsMenuOptions: Record MenuOption | MenuOption[] | undefined> = { undoRmTab: () => ({ @@ -503,6 +504,144 @@ export const tabsMenuOptions: Record MenuOption | MenuOption[] | u return option }, + sortTabsByTitleAscending: () => { + const option: MenuOption = { + label: translate('menu.tab.sort_by_title_asc'), + icon: 'icon_sort_name_asc', + onClick: () => TabsSorting.sort(TabsSorting.By.Title, Selection.get(), 1), + onAltClick: () => TabsSorting.sort(TabsSorting.By.Title, Selection.get(), 1, true), + } + if (!Settings.state.ctxMenuRenderInact && option.inactive) return + return option + }, + + sortTabsByTitleDescending: () => { + const option: MenuOption = { + label: translate('menu.tab.sort_by_title_des'), + icon: 'icon_sort_name_des', + onClick: () => TabsSorting.sort(TabsSorting.By.Title, Selection.get(), -1), + onAltClick: () => TabsSorting.sort(TabsSorting.By.Title, Selection.get(), -1, true), + } + if (!Settings.state.ctxMenuRenderInact && option.inactive) return + return option + }, + + sortTabsByUrlAscending: () => { + const option: MenuOption = { + label: translate('menu.tab.sort_by_url_asc'), + icon: 'icon_sort_url_asc', + onClick: () => TabsSorting.sort(TabsSorting.By.Url, Selection.get(), 1), + onAltClick: () => TabsSorting.sort(TabsSorting.By.Url, Selection.get(), 1, true), + } + if (!Settings.state.ctxMenuRenderInact && option.inactive) return + return option + }, + + sortTabsByUrlDescending: () => { + const option: MenuOption = { + label: translate('menu.tab.sort_by_url_des'), + icon: 'icon_sort_url_des', + onClick: () => TabsSorting.sort(TabsSorting.By.Url, Selection.get(), -1), + onAltClick: () => TabsSorting.sort(TabsSorting.By.Url, Selection.get(), -1, true), + } + if (!Settings.state.ctxMenuRenderInact && option.inactive) return + return option + }, + + sortTabsByAccessTimeAscending: () => { + const option: MenuOption = { + label: translate('menu.tab.sort_by_time_asc'), + icon: 'icon_sort_time_asc', + onClick: () => TabsSorting.sort(TabsSorting.By.ATime, Selection.get(), 1), + onAltClick: () => TabsSorting.sort(TabsSorting.By.ATime, Selection.get(), 1, true), + } + if (!Settings.state.ctxMenuRenderInact && option.inactive) return + return option + }, + + sortTabsByAccessTimeDescending: () => { + const option: MenuOption = { + label: translate('menu.tab.sort_by_time_des'), + icon: 'icon_sort_time_des', + onClick: () => TabsSorting.sort(TabsSorting.By.ATime, Selection.get(), -1), + onAltClick: () => TabsSorting.sort(TabsSorting.By.ATime, Selection.get(), -1, true), + } + if (!Settings.state.ctxMenuRenderInact && option.inactive) return + return option + }, + + sortTabsTreeByTitleAscending: () => { + const ids = Selection.get() + const option: MenuOption = { + label: translate('menu.tab.sort_tree_by_title_asc'), + icon: 'icon_sort_name_asc', + onClick: () => TabsSorting.sort(TabsSorting.By.Title, ids, 1, true), + } + if (ids.length === 1 && !Tabs.byId[ids[0]]?.isParent) option.inactive = true + if (!Settings.state.ctxMenuRenderInact && option.inactive) return + return option + }, + + sortTabsTreeByTitleDescending: () => { + const ids = Selection.get() + const option: MenuOption = { + label: translate('menu.tab.sort_tree_by_title_des'), + icon: 'icon_sort_name_des', + onClick: () => TabsSorting.sort(TabsSorting.By.Title, ids, -1, true), + } + if (ids.length === 1 && !Tabs.byId[ids[0]]?.isParent) option.inactive = true + if (!Settings.state.ctxMenuRenderInact && option.inactive) return + return option + }, + + sortTabsTreeByUrlAscending: () => { + const ids = Selection.get() + const option: MenuOption = { + label: translate('menu.tab.sort_tree_by_url_asc'), + icon: 'icon_sort_url_asc', + onClick: () => TabsSorting.sort(TabsSorting.By.Url, ids, 1, true), + } + if (ids.length === 1 && !Tabs.byId[ids[0]]?.isParent) option.inactive = true + if (!Settings.state.ctxMenuRenderInact && option.inactive) return + return option + }, + + sortTabsTreeByUrlDescending: () => { + const ids = Selection.get() + const option: MenuOption = { + label: translate('menu.tab.sort_tree_by_url_des'), + icon: 'icon_sort_url_des', + onClick: () => TabsSorting.sort(TabsSorting.By.Url, ids, -1, true), + } + if (ids.length === 1 && !Tabs.byId[ids[0]]?.isParent) option.inactive = true + if (!Settings.state.ctxMenuRenderInact && option.inactive) return + return option + }, + + sortTabsTreeByAccessTimeAscending: () => { + const ids = Selection.get() + const option: MenuOption = { + label: translate('menu.tab.sort_tree_by_time_asc'), + icon: 'icon_sort_time_asc', + onClick: () => TabsSorting.sort(TabsSorting.By.ATime, ids, 1, true), + } + if (ids.length === 1 && !Tabs.byId[ids[0]]?.isParent) option.inactive = true + if (!Settings.state.ctxMenuRenderInact && option.inactive) return + return option + }, + + sortTabsTreeByAccessTimeDescending: () => { + const ids = Selection.get() + const option: MenuOption = { + label: translate('menu.tab.sort_tree_by_time_des'), + icon: 'icon_sort_time_des', + onClick: () => TabsSorting.sort(TabsSorting.By.ATime, ids, -1, true), + } + if (ids.length === 1 && !Tabs.byId[ids[0]]?.isParent) option.inactive = true + if (!Settings.state.ctxMenuRenderInact && option.inactive) return + return option + }, + // --- // -- Panel options // - @@ -686,4 +825,88 @@ export const tabsMenuOptions: Record MenuOption | MenuOption[] | u return option }, + + sortAllTabsByTitleAscending: () => { + const panel = Sidebar.panelsById[Selection.getFirst()] + if (!Utils.isTabsPanel(panel)) return + const ids = panel.tabs.map(t => t.id) + const option: MenuOption = { + label: translate('menu.tabs_panel.sort_all_by_title_asc'), + icon: 'icon_sort_name_asc', + inactive: ids.length < 2, + onClick: () => TabsSorting.sort(TabsSorting.By.Title, ids, 1, true), + } + if (!Settings.state.ctxMenuRenderInact && option.inactive) return + return option + }, + + sortAllTabsByTitleDescending: () => { + const panel = Sidebar.panelsById[Selection.getFirst()] + if (!Utils.isTabsPanel(panel)) return + const ids = panel.tabs.map(t => t.id) + const option: MenuOption = { + label: translate('menu.tabs_panel.sort_all_by_title_des'), + icon: 'icon_sort_name_des', + inactive: ids.length < 2, + onClick: () => TabsSorting.sort(TabsSorting.By.Title, ids, -1, true), + } + if (!Settings.state.ctxMenuRenderInact && option.inactive) return + return option + }, + + sortAllTabsByUrlAscending: () => { + const panel = Sidebar.panelsById[Selection.getFirst()] + if (!Utils.isTabsPanel(panel)) return + const ids = panel.tabs.map(t => t.id) + const option: MenuOption = { + label: translate('menu.tabs_panel.sort_all_by_url_asc'), + icon: 'icon_sort_url_asc', + inactive: ids.length < 2, + onClick: () => TabsSorting.sort(TabsSorting.By.Url, ids, 1, true), + } + if (!Settings.state.ctxMenuRenderInact && option.inactive) return + return option + }, + + sortAllTabsByUrlDescending: () => { + const panel = Sidebar.panelsById[Selection.getFirst()] + if (!Utils.isTabsPanel(panel)) return + const ids = panel.tabs.map(t => t.id) + const option: MenuOption = { + label: translate('menu.tabs_panel.sort_all_by_url_des'), + icon: 'icon_sort_url_des', + inactive: ids.length < 2, + onClick: () => TabsSorting.sort(TabsSorting.By.Url, ids, -1, true), + } + if (!Settings.state.ctxMenuRenderInact && option.inactive) return + return option + }, + + sortAllTabsByAccessTimeAscending: () => { + const panel = Sidebar.panelsById[Selection.getFirst()] + if (!Utils.isTabsPanel(panel)) return + const ids = panel.tabs.map(t => t.id) + const option: MenuOption = { + label: translate('menu.tabs_panel.sort_all_by_time_asc'), + icon: 'icon_sort_time_asc', + inactive: ids.length < 2, + onClick: () => TabsSorting.sort(TabsSorting.By.ATime, ids, 1, true), + } + if (!Settings.state.ctxMenuRenderInact && option.inactive) return + return option + }, + + sortAllTabsByAccessTimeDescending: () => { + const panel = Sidebar.panelsById[Selection.getFirst()] + if (!Utils.isTabsPanel(panel)) return + const ids = panel.tabs.map(t => t.id) + const option: MenuOption = { + label: translate('menu.tabs_panel.sort_all_by_time_des'), + icon: 'icon_sort_time_des', + inactive: ids.length < 2, + onClick: () => TabsSorting.sort(TabsSorting.By.ATime, ids, -1, true), + } + if (!Settings.state.ctxMenuRenderInact && option.inactive) return + return option + }, } diff --git a/src/services/tabs.fg.sorting.ts b/src/services/tabs.fg.sorting.ts new file mode 100644 index 000000000..72c56d666 --- /dev/null +++ b/src/services/tabs.fg.sorting.ts @@ -0,0 +1,257 @@ +import * as Logs from 'src/services/logs' +import { Tabs } from 'src/services/tabs.fg' +import { Sidebar } from './sidebar' +import { Settings } from './settings' +import { isTabsPanel } from 'src/utils' +import { NativeTab, Tab } from 'src/types' +import { Windows } from './windows' +import { Notifications } from './notifications' +import { translate } from 'src/dict' + +export const enum By { + Title = 1, + Url = 2, + ATime = 3, +} + +let stopSorting = false + +export async function sort(type: By, ids: ID[], dir = 0, tree?: boolean) { + if (!ids.length || !dir) return + + if (tree) { + // Include child tabs + const treeIds: Set = new Set() + for (const id of ids) { + const tab = Tabs.byId[id] + if (!tab || treeIds.has(tab.id)) continue + treeIds.add(tab.id) + if (tab.isParent) Tabs.getBranch(tab, false).forEach(t => treeIds.add(t.id)) + } + ids = Array.from(treeIds) + } else { + // Exclude invisible (folded) tabs + ids = ids.filter(id => { + const tab = Tabs.byId[id] + return !tab?.invisible + }) + + // Normalize selection with only one tab: Select all siblings + if (ids.length === 1) { + const siblings = getSiblings(ids[0]) + if (!siblings) return + ids = siblings + } + } + + // Sort input + Tabs.sortTabIds(ids) + + // Split by sorting chunks (per parent tab) and reverse them + const sortingChunks = getSortingChunks(ids).reverse() + + // Lock sidebar and show progress notification + Tabs.sorting = true + let progressNotification + stopSorting = false + if (sortingChunks.length > 1 || ids.length > 2) { + progressNotification = Notifications.progress({ + icon: getNotifIcon(By.Title, dir), + title: translate('notif.tabs_sort'), + progress: { percent: -1 }, + unconcealed: true, + ctrl: translate('btn.stop'), + callback: () => { + stopSorting = true + }, + }) + } + + // Sort by title + if (type === By.Title) { + await sortTabsInChunks(sortingChunks, (aId, bId) => { + const aTab = Tabs.byId[aId] + const bTab = Tabs.byId[bId] + if (!aTab || !bTab) return 0 + const aTitle = aTab.customTitle ?? aTab.title + const bTitle = bTab.customTitle ?? bTab.title + if (dir > 0) return aTitle.localeCompare(bTitle) + else return bTitle.localeCompare(aTitle) + }).catch(() => {}) + } + + // or Sort by URL + else if (type === By.Url) { + await sortTabsInChunks(sortingChunks, (aId, bId) => { + const aTab = Tabs.byId[aId] + const bTab = Tabs.byId[bId] + if (!aTab || !bTab) return 0 + const aIndex = aTab.url.indexOf('://') + const bIndex = bTab.url.indexOf('://') + const aLink = aIndex === -1 ? aTab.url : aTab.url.slice(aIndex + 3) + const bLink = bIndex === -1 ? bTab.url : bTab.url.slice(bIndex + 3) + if (dir > 0) return aLink.localeCompare(bLink) + else return bLink.localeCompare(aLink) + }).catch(() => {}) + } + + // or Sort by access time + else if (type === By.ATime) { + await sortTabsInChunks(sortingChunks, (aId, bId) => { + const aTab = Tabs.byId[aId] + const bTab = Tabs.byId[bId] + if (!aTab || !bTab) return 0 + if (dir > 0) return aTab.lastAccessed - bTab.lastAccessed + else return bTab.lastAccessed - aTab.lastAccessed + }).catch(() => {}) + } + + // Unlock sidebar and hide progress notification + if (progressNotification) Notifications.finishProgress(progressNotification) + Tabs.sorting = false + Tabs.deferredEventHandling.forEach(cb => cb()) + Tabs.deferredEventHandling = [] +} + +function getNotifIcon(type: By, dir = 0): string | undefined { + if (!dir) return + + let notifIcon: string | undefined + if (type === By.Title) { + if (dir > 0) notifIcon = '#icon_sort_name_asc' + else notifIcon = '#icon_sort_name_des' + } else if (type === By.Url) { + if (dir > 0) notifIcon = '#icon_sort_url_asc' + else notifIcon = '#icon_sort_url_des' + } else if (type === By.ATime) { + if (dir > 0) notifIcon = '#icon_sort_time_asc' + else notifIcon = '#icon_sort_time_des' + } + + return notifIcon +} + +function getSiblings(id: ID): ID[] | undefined { + const tab = Tabs.byId[id] + if (!tab) return + + const panel = Sidebar.panelsById[tab.panelId] + + // Global pinned tabs + if (tab.pinned && Settings.state.pinnedTabsPosition !== 'panel') { + return Tabs.pinned.map(t => t.id) + } + + // In panel pinned tabs + else if (tab.pinned && isTabsPanel(panel)) { + return panel.pinnedTabs.map(t => t.id) + } + + // Normal tabs + else if (isTabsPanel(panel)) { + return panel.tabs.filter(t => t.parentId === tab.parentId).map(t => t.id) + } else { + return + } +} + +function getSortingChunks(ids: ID[]): ID[][] { + const groups: ID[][] = [] + const parentIndexes: Partial> = {} + + for (const id of ids) { + const tab = Tabs.byId[id] + if (!tab) continue + + // Immediately return pinned tabs group + if (tab.pinned) return [ids] + + let index = parentIndexes[tab.parentId] + if (index === undefined) { + index = groups.push([id]) - 1 + parentIndexes[tab.parentId] = index + } else { + groups[index].push(id) + } + } + + return groups +} + +async function sortTabsInChunks(sortingGroups: ID[][], sortFn: (a: ID, b: ID) => number) { + let tabProbe: Tab | undefined + + for (const group of sortingGroups) { + if (group.length <= 1) continue + + // Get target index + const startTab = Tabs.byId[group[0]] + const startIndex = startTab?.index + if (!startTab || startIndex === undefined) continue + if (!tabProbe) tabProbe = startTab + + // Get list of tabs to cut (including child tabs) + const branches: Record = {} + const toCut: Tab[] = [] + for (const id of group) { + const tab = Tabs.byId[id] + if (!tab) continue + toCut.push(tab) + if (tab.isParent) { + const branch = Tabs.getBranch(tab, false) + branches[tab.id] = branch + toCut.push(...branch) + } + } + + // Cut tabs from local list (reversely) + for (let i = toCut.length; i--; ) { + const tab = toCut[i] + if (!tab) continue + + tab.moving = true + Tabs.list.splice(tab.index, 1) + } + + // Sort + group.sort(sortFn) + + // Get list of tabs to paste (including child tabs) + const toPaste: Tab[] = [] + for (const id of group) { + const tab = Tabs.byId[id] + if (!tab) continue + toPaste.push(tab) + const branch = branches[tab.id] + if (branch) toPaste.push(...branch) + } + + // Paste tabs to the new index + Tabs.list.splice(startIndex, 0, ...toPaste) + + // Update indexes + Tabs.updateTabsIndexes(startIndex) + + // Move native tabs + const toPasteIds = toPaste.map(t => t.id) + try { + await browser.tabs.move(toPasteIds, { index: startIndex, windowId: Windows.id }) + } catch (err) { + Logs.err('Tabs.sortNativeTabs: Cannot move tabs', err) + return Tabs.reinitTabs() + } + toPaste.forEach(t => (t.moving = undefined)) + + if (stopSorting) { + stopSorting = false + break + } + } + + // Update internal state + Tabs.updateTabsTree() + Sidebar.recalcTabsPanels() + if (tabProbe && !tabProbe.pinned) { + Sidebar.recalcVisibleTabs(tabProbe.panelId) + } +}