diff --git a/src/components/layout/workspace-chrome-controller-source.test.ts b/src/components/layout/workspace-chrome-controller-source.test.ts index 79b53e664..442d13ade 100644 --- a/src/components/layout/workspace-chrome-controller-source.test.ts +++ b/src/components/layout/workspace-chrome-controller-source.test.ts @@ -32,11 +32,16 @@ describe("tab close/navigation shortcuts live in the always-mounted controller", it("registers the tab shortcuts in WorkspaceChromeController", () => { expect(controllerSource).toMatch(/shortcuts\.next_tab/) expect(controllerSource).toMatch(/shortcuts\.prev_tab/) + expect(controllerSource).toMatch(/numberedTabIndexFromEvent/) + expect(controllerSource).toMatch(/pickNumberedTabId/) expect(controllerSource).toMatch(/shortcuts\.close_current_tab/) + expect(controllerSource).toMatch(/shortcuts\.reopen_last_closed_tab/) + expect(controllerSource).toMatch(/popClosedTab/) expect(controllerSource).toMatch(/shortcuts\.close_all_file_tabs/) // ...and actually drives the tab / file-tab actions. The e.preventDefault() // calls next to these are what stop mod+w reaching the window-close default. expect(controllerSource).toMatch(/switchTab\(/) + expect(controllerSource).toMatch(/switchFileTab\(/) expect(controllerSource).toMatch(/closeTab\(/) expect(controllerSource).toMatch(/closeFileTab\(/) expect(controllerSource).toMatch(/closeAllFileTabs\(/) diff --git a/src/components/layout/workspace-chrome-controller.tsx b/src/components/layout/workspace-chrome-controller.tsx index cb0f5b372..fd8b9a9a6 100644 --- a/src/components/layout/workspace-chrome-controller.tsx +++ b/src/components/layout/workspace-chrome-controller.tsx @@ -16,7 +16,13 @@ import { import { useWorkbenchRoute } from "@/contexts/workbench-route-context" import { useSearchDialog } from "@/contexts/search-dialog-context" import { useShortcutSettings } from "@/hooks/use-shortcut-settings" -import { matchShortcutEvent } from "@/lib/keyboard-shortcuts" +import { useAppWorkspaceStore } from "@/stores/app-workspace-store" +import { popClosedTab } from "@/lib/closed-tab-stack" +import { + matchShortcutEvent, + numberedTabIndexFromEvent, + pickNumberedTabId, +} from "@/lib/keyboard-shortcuts" import { SearchCommandDialog } from "@/components/conversations/search-command-dialog" import { WorkspaceFolderDialog } from "@/components/layout/workspace-folder-dialog" @@ -34,15 +40,17 @@ export function WorkspaceChromeController() { const { toggle } = useSidebarContext() const { toggle: toggleAuxPanel } = useAuxPanelContext() const { toggle: toggleTerminal } = useTerminalContext() - const { openNewConversationTab, switchTab, closeTab } = useTabActions() + const { openNewConversationTab, openTab, switchTab, closeTab } = + useTabActions() const tabs = useTabStore((s) => s.tabs) const activeTabId = useTabStore((s) => s.activeTabId) // Tab-close/navigation shortcuts used to live in the visible tab strips. // Mobile no longer mounts those strips, so this always-mounted controller now // owns them too (see the keydown handler below). const { mode, activePane, filesMaximized } = useWorkspaceView() - const { activeFileTabId } = useWorkspaceFileTabs() - const { closeFileTab, closeAllFileTabs } = useWorkspaceActions() + const { activeFileTabId, fileTabs } = useWorkspaceFileTabs() + const { closeFileTab, closeAllFileTabs, switchFileTab, openFilePreview } = + useWorkspaceActions() const { openConversations } = useWorkbenchRoute() const { shortcuts } = useShortcutSettings() // Search open-state is shared (see search-dialog-context): the trigger lives @@ -132,6 +140,31 @@ export function WorkspaceChromeController() { return } + const numberedIndex = numberedTabIndexFromEvent(e, shortcuts) + if (numberedIndex !== null) { + if (conversationPaneActive) { + const tabId = pickNumberedTabId( + tabs.map((tab) => tab.id), + numberedIndex + ) + if (!tabId) return + e.preventDefault() + switchTab(tabId) + return + } + if (filesPaneActive) { + const tabId = pickNumberedTabId( + fileTabs.map((tab) => tab.id), + numberedIndex + ) + if (!tabId) return + e.preventDefault() + switchFileTab(tabId) + return + } + return + } + if (matchShortcutEvent(e, shortcuts.close_all_file_tabs)) { if (!filesPaneActive) return e.preventDefault() @@ -149,6 +182,40 @@ export function WorkspaceChromeController() { e.preventDefault() closeFileTab(activeFileTabId) } + return + } + + if (matchShortcutEvent(e, shortcuts.reopen_last_closed_tab)) { + e.preventDefault() + while (true) { + const closed = popClosedTab() + if (!closed) return + if (closed.kind === "file") { + void openFilePreview(closed.path, { + folderId: closed.folderId ?? undefined, + }) + return + } + if (closed.conversationId != null) { + openConversations() + openTab( + closed.folderId, + closed.conversationId, + closed.agentType, + closed.isPinned, + closed.title + ) + return + } + const folder = useAppWorkspaceStore + .getState() + .getFolder(closed.folderId) + const workingDir = closed.workingDir ?? folder?.path + if (!workingDir) continue + openConversations() + openNewConversationTab(closed.folderId, workingDir) + return + } } } document.addEventListener("keydown", handleKeyDown) @@ -159,6 +226,8 @@ export function WorkspaceChromeController() { handleOpenSettings, openConversations, openNewConversationTab, + openTab, + openFilePreview, setSearchOpen, shortcuts, toggle, @@ -172,9 +241,11 @@ export function WorkspaceChromeController() { mode, activePane, filesMaximized, + fileTabs, activeFileTabId, closeFileTab, closeAllFileTabs, + switchFileTab, ]) return ( diff --git a/src/contexts/workspace-context.tsx b/src/contexts/workspace-context.tsx index ca94a1af4..f59910b1c 100644 --- a/src/contexts/workspace-context.tsx +++ b/src/contexts/workspace-context.tsx @@ -36,6 +36,7 @@ import { splitAbsPath, } from "@/lib/file-open-target" import { isAbsoluteFilePath } from "@/lib/file-path-display" +import { pushClosedTab, snapshotFileTab } from "@/lib/closed-tab-stack" import { isHtmlPreviewable, isImageFile, @@ -2139,6 +2140,9 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { if (!confirmed) return prev } + const closed = snapshotFileTab(tab) + if (closed) pushClosedTab(closed) + const next = prev.filter((candidate) => candidate.id !== tabId) setActiveFileTabId((current) => { @@ -2204,6 +2208,11 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { if (!confirmed) return prev } + for (const tab of prev) { + const closed = snapshotFileTab(tab) + if (closed) pushClosedTab(closed) + } + inFlightLoadsRef.current.clear() setActiveFileTabId(null) setPreviewFileTabIds(new Set()) diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index bdf81590d..6a1448fd9 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -392,6 +392,10 @@ "title": "إغلاق التبويب الحالي", "description": "إغلاق المحادثة الحالية أو تبويب الملف الحالي" }, + "reopen_last_closed_tab": { + "title": "إعادة فتح التبويب المغلق", + "description": "إعادة فتح آخر تبويب محادثة أو ملف تم إغلاقه" + }, "close_all_file_tabs": { "title": "إغلاق جميع تبويبات الملفات", "description": "إغلاق جميع تبويبات الملفات المفتوحة عندما تكون لوحة الملفات نشطة" @@ -404,6 +408,42 @@ "title": "علامة التبويب السابقة", "description": "التبديل إلى علامة تبويب المحادثة أو الملف السابقة" }, + "switch_tab_1": { + "title": "التبديل إلى التبويب 1", + "description": "الانتقال إلى تبويب المحادثة أو الملف رقم 1" + }, + "switch_tab_2": { + "title": "التبديل إلى التبويب 2", + "description": "الانتقال إلى تبويب المحادثة أو الملف رقم 2" + }, + "switch_tab_3": { + "title": "التبديل إلى التبويب 3", + "description": "الانتقال إلى تبويب المحادثة أو الملف رقم 3" + }, + "switch_tab_4": { + "title": "التبديل إلى التبويب 4", + "description": "الانتقال إلى تبويب المحادثة أو الملف رقم 4" + }, + "switch_tab_5": { + "title": "التبديل إلى التبويب 5", + "description": "الانتقال إلى تبويب المحادثة أو الملف رقم 5" + }, + "switch_tab_6": { + "title": "التبديل إلى التبويب 6", + "description": "الانتقال إلى تبويب المحادثة أو الملف رقم 6" + }, + "switch_tab_7": { + "title": "التبديل إلى التبويب 7", + "description": "الانتقال إلى تبويب المحادثة أو الملف رقم 7" + }, + "switch_tab_8": { + "title": "التبديل إلى التبويب 8", + "description": "الانتقال إلى تبويب المحادثة أو الملف رقم 8" + }, + "switch_tab_9": { + "title": "التبديل إلى التبويب 9", + "description": "الانتقال إلى تبويب المحادثة أو الملف رقم 9" + }, "send_message": { "title": "إرسال الرسالة", "description": "إرسال الرسالة الحالية في مربع الإدخال" diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 0c40db5d3..9d604b43a 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -392,6 +392,10 @@ "title": "Aktuellen Tab schließen", "description": "Schließt den aktuellen Konversations- oder Dateitab" }, + "reopen_last_closed_tab": { + "title": "Geschlossenen Tab wiederöffnen", + "description": "Den zuletzt geschlossenen Konversations- oder Datei-Tab wieder öffnen" + }, "close_all_file_tabs": { "title": "Alle Dateitabs schließen", "description": "Schließt alle geöffneten Dateitabs, wenn der Dateibereich aktiv ist" @@ -404,6 +408,42 @@ "title": "Vorheriger Tab", "description": "Zum vorherigen Konversations- oder Datei-Tab wechseln" }, + "switch_tab_1": { + "title": "Zu Tab 1 wechseln", + "description": "Zum 1. Konversations- oder Datei-Tab springen" + }, + "switch_tab_2": { + "title": "Zu Tab 2 wechseln", + "description": "Zum 2. Konversations- oder Datei-Tab springen" + }, + "switch_tab_3": { + "title": "Zu Tab 3 wechseln", + "description": "Zum 3. Konversations- oder Datei-Tab springen" + }, + "switch_tab_4": { + "title": "Zu Tab 4 wechseln", + "description": "Zum 4. Konversations- oder Datei-Tab springen" + }, + "switch_tab_5": { + "title": "Zu Tab 5 wechseln", + "description": "Zum 5. Konversations- oder Datei-Tab springen" + }, + "switch_tab_6": { + "title": "Zu Tab 6 wechseln", + "description": "Zum 6. Konversations- oder Datei-Tab springen" + }, + "switch_tab_7": { + "title": "Zu Tab 7 wechseln", + "description": "Zum 7. Konversations- oder Datei-Tab springen" + }, + "switch_tab_8": { + "title": "Zu Tab 8 wechseln", + "description": "Zum 8. Konversations- oder Datei-Tab springen" + }, + "switch_tab_9": { + "title": "Zu Tab 9 wechseln", + "description": "Zum 9. Konversations- oder Datei-Tab springen" + }, "send_message": { "title": "Nachricht senden", "description": "Die aktuelle Nachricht im Eingabefeld senden" diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 273af3aa0..5a5c7b50f 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -392,6 +392,10 @@ "title": "Close Current Tab", "description": "Close current conversation or file tab" }, + "reopen_last_closed_tab": { + "title": "Reopen Closed Tab", + "description": "Reopen the most recently closed conversation or file tab" + }, "close_all_file_tabs": { "title": "Close All File Tabs", "description": "Close all open file tabs when the file pane is active" @@ -404,6 +408,42 @@ "title": "Previous Tab", "description": "Switch to the previous conversation or file tab" }, + "switch_tab_1": { + "title": "Switch to Tab 1", + "description": "Jump to conversation or file tab 1" + }, + "switch_tab_2": { + "title": "Switch to Tab 2", + "description": "Jump to conversation or file tab 2" + }, + "switch_tab_3": { + "title": "Switch to Tab 3", + "description": "Jump to conversation or file tab 3" + }, + "switch_tab_4": { + "title": "Switch to Tab 4", + "description": "Jump to conversation or file tab 4" + }, + "switch_tab_5": { + "title": "Switch to Tab 5", + "description": "Jump to conversation or file tab 5" + }, + "switch_tab_6": { + "title": "Switch to Tab 6", + "description": "Jump to conversation or file tab 6" + }, + "switch_tab_7": { + "title": "Switch to Tab 7", + "description": "Jump to conversation or file tab 7" + }, + "switch_tab_8": { + "title": "Switch to Tab 8", + "description": "Jump to conversation or file tab 8" + }, + "switch_tab_9": { + "title": "Switch to Tab 9", + "description": "Jump to conversation or file tab 9" + }, "send_message": { "title": "Send Message", "description": "Send the current message in the input box" diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 9948070a4..a924fed6e 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -392,6 +392,10 @@ "title": "Cerrar pestaña actual", "description": "Cierra la conversación o pestaña de archivo actual" }, + "reopen_last_closed_tab": { + "title": "Reabrir pestaña cerrada", + "description": "Reabrir la última pestaña de conversación o archivo cerrada" + }, "close_all_file_tabs": { "title": "Cerrar todas las pestañas de archivos", "description": "Cierra todas las pestañas de archivos abiertas cuando el panel de archivos está activo" @@ -404,6 +408,42 @@ "title": "Pestaña anterior", "description": "Cambiar a la pestaña anterior de conversación o archivo" }, + "switch_tab_1": { + "title": "Ir a la pestaña 1", + "description": "Saltar a la pestaña de conversación o archivo 1" + }, + "switch_tab_2": { + "title": "Ir a la pestaña 2", + "description": "Saltar a la pestaña de conversación o archivo 2" + }, + "switch_tab_3": { + "title": "Ir a la pestaña 3", + "description": "Saltar a la pestaña de conversación o archivo 3" + }, + "switch_tab_4": { + "title": "Ir a la pestaña 4", + "description": "Saltar a la pestaña de conversación o archivo 4" + }, + "switch_tab_5": { + "title": "Ir a la pestaña 5", + "description": "Saltar a la pestaña de conversación o archivo 5" + }, + "switch_tab_6": { + "title": "Ir a la pestaña 6", + "description": "Saltar a la pestaña de conversación o archivo 6" + }, + "switch_tab_7": { + "title": "Ir a la pestaña 7", + "description": "Saltar a la pestaña de conversación o archivo 7" + }, + "switch_tab_8": { + "title": "Ir a la pestaña 8", + "description": "Saltar a la pestaña de conversación o archivo 8" + }, + "switch_tab_9": { + "title": "Ir a la pestaña 9", + "description": "Saltar a la pestaña de conversación o archivo 9" + }, "send_message": { "title": "Enviar mensaje", "description": "Enviar el mensaje actual en el cuadro de entrada" diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 9638508d6..c5b304193 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -392,6 +392,10 @@ "title": "Fermer l’onglet actuel", "description": "Fermer la conversation actuelle ou l’onglet de fichier" }, + "reopen_last_closed_tab": { + "title": "Rouvrir l'onglet fermé", + "description": "Rouvrir le dernier onglet de conversation ou de fichier fermé" + }, "close_all_file_tabs": { "title": "Fermer tous les onglets de fichiers", "description": "Fermer tous les onglets de fichiers ouverts lorsque le panneau de fichiers est actif" @@ -404,6 +408,42 @@ "title": "Onglet précédent", "description": "Passer à l'onglet de conversation ou de fichier précédent" }, + "switch_tab_1": { + "title": "Aller à l'onglet 1", + "description": "Ouvrir l'onglet de conversation ou de fichier 1" + }, + "switch_tab_2": { + "title": "Aller à l'onglet 2", + "description": "Ouvrir l'onglet de conversation ou de fichier 2" + }, + "switch_tab_3": { + "title": "Aller à l'onglet 3", + "description": "Ouvrir l'onglet de conversation ou de fichier 3" + }, + "switch_tab_4": { + "title": "Aller à l'onglet 4", + "description": "Ouvrir l'onglet de conversation ou de fichier 4" + }, + "switch_tab_5": { + "title": "Aller à l'onglet 5", + "description": "Ouvrir l'onglet de conversation ou de fichier 5" + }, + "switch_tab_6": { + "title": "Aller à l'onglet 6", + "description": "Ouvrir l'onglet de conversation ou de fichier 6" + }, + "switch_tab_7": { + "title": "Aller à l'onglet 7", + "description": "Ouvrir l'onglet de conversation ou de fichier 7" + }, + "switch_tab_8": { + "title": "Aller à l'onglet 8", + "description": "Ouvrir l'onglet de conversation ou de fichier 8" + }, + "switch_tab_9": { + "title": "Aller à l'onglet 9", + "description": "Ouvrir l'onglet de conversation ou de fichier 9" + }, "send_message": { "title": "Envoyer le message", "description": "Envoyer le message actuel dans la zone de saisie" diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index f44e8c88d..00cc3dae0 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -392,6 +392,10 @@ "title": "現在のタブを閉じる", "description": "現在の会話またはファイルタブを閉じます" }, + "reopen_last_closed_tab": { + "title": "閉じたタブを再開", + "description": "直前に閉じた会話またはファイルタブを開く" + }, "close_all_file_tabs": { "title": "すべてのファイルタブを閉じる", "description": "ファイルペインがアクティブなときに開いているすべてのファイルタブを閉じます" @@ -404,6 +408,42 @@ "title": "前のタブ", "description": "前の会話またはファイルタブに切り替える" }, + "switch_tab_1": { + "title": "タブ 1 に切り替え", + "description": "1 番目の会話またはファイルタブに移動" + }, + "switch_tab_2": { + "title": "タブ 2 に切り替え", + "description": "2 番目の会話またはファイルタブに移動" + }, + "switch_tab_3": { + "title": "タブ 3 に切り替え", + "description": "3 番目の会話またはファイルタブに移動" + }, + "switch_tab_4": { + "title": "タブ 4 に切り替え", + "description": "4 番目の会話またはファイルタブに移動" + }, + "switch_tab_5": { + "title": "タブ 5 に切り替え", + "description": "5 番目の会話またはファイルタブに移動" + }, + "switch_tab_6": { + "title": "タブ 6 に切り替え", + "description": "6 番目の会話またはファイルタブに移動" + }, + "switch_tab_7": { + "title": "タブ 7 に切り替え", + "description": "7 番目の会話またはファイルタブに移動" + }, + "switch_tab_8": { + "title": "タブ 8 に切り替え", + "description": "8 番目の会話またはファイルタブに移動" + }, + "switch_tab_9": { + "title": "タブ 9 に切り替え", + "description": "9 番目の会話またはファイルタブに移動" + }, "send_message": { "title": "メッセージを送信", "description": "入力欄のメッセージを送信する" diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index d8c404715..0d0973859 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -392,6 +392,10 @@ "title": "현재 탭 닫기", "description": "현재 대화 또는 파일 탭을 닫습니다" }, + "reopen_last_closed_tab": { + "title": "닫은 탭 다시 열기", + "description": "가장 최근에 닫은 대화 또는 파일 탭을 다시 엽니다" + }, "close_all_file_tabs": { "title": "모든 파일 탭 닫기", "description": "파일 패널이 활성 상태일 때 열린 모든 파일 탭을 닫습니다" @@ -404,6 +408,42 @@ "title": "이전 탭", "description": "이전 대화 또는 파일 탭으로 전환" }, + "switch_tab_1": { + "title": "1번 탭으로 이동", + "description": "1번째 대화 또는 파일 탭으로 이동" + }, + "switch_tab_2": { + "title": "2번 탭으로 이동", + "description": "2번째 대화 또는 파일 탭으로 이동" + }, + "switch_tab_3": { + "title": "3번 탭으로 이동", + "description": "3번째 대화 또는 파일 탭으로 이동" + }, + "switch_tab_4": { + "title": "4번 탭으로 이동", + "description": "4번째 대화 또는 파일 탭으로 이동" + }, + "switch_tab_5": { + "title": "5번 탭으로 이동", + "description": "5번째 대화 또는 파일 탭으로 이동" + }, + "switch_tab_6": { + "title": "6번 탭으로 이동", + "description": "6번째 대화 또는 파일 탭으로 이동" + }, + "switch_tab_7": { + "title": "7번 탭으로 이동", + "description": "7번째 대화 또는 파일 탭으로 이동" + }, + "switch_tab_8": { + "title": "8번 탭으로 이동", + "description": "8번째 대화 또는 파일 탭으로 이동" + }, + "switch_tab_9": { + "title": "9번 탭으로 이동", + "description": "9번째 대화 또는 파일 탭으로 이동" + }, "send_message": { "title": "메시지 보내기", "description": "입력창에서 현재 메시지를 전송" diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 5b31f9ead..a66fb9e6d 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -392,6 +392,10 @@ "title": "Fechar aba atual", "description": "Fecha a conversa atual ou aba de arquivo" }, + "reopen_last_closed_tab": { + "title": "Reabrir aba fechada", + "description": "Reabrir a última aba de conversa ou arquivo fechada" + }, "close_all_file_tabs": { "title": "Fechar todas as abas de arquivo", "description": "Fecha todas as abas de arquivo abertas quando o painel de arquivos está ativo" @@ -404,6 +408,42 @@ "title": "Aba anterior", "description": "Mudar para a aba anterior de conversa ou arquivo" }, + "switch_tab_1": { + "title": "Ir para a aba 1", + "description": "Saltar para a aba de conversa ou arquivo 1" + }, + "switch_tab_2": { + "title": "Ir para a aba 2", + "description": "Saltar para a aba de conversa ou arquivo 2" + }, + "switch_tab_3": { + "title": "Ir para a aba 3", + "description": "Saltar para a aba de conversa ou arquivo 3" + }, + "switch_tab_4": { + "title": "Ir para a aba 4", + "description": "Saltar para a aba de conversa ou arquivo 4" + }, + "switch_tab_5": { + "title": "Ir para a aba 5", + "description": "Saltar para a aba de conversa ou arquivo 5" + }, + "switch_tab_6": { + "title": "Ir para a aba 6", + "description": "Saltar para a aba de conversa ou arquivo 6" + }, + "switch_tab_7": { + "title": "Ir para a aba 7", + "description": "Saltar para a aba de conversa ou arquivo 7" + }, + "switch_tab_8": { + "title": "Ir para a aba 8", + "description": "Saltar para a aba de conversa ou arquivo 8" + }, + "switch_tab_9": { + "title": "Ir para a aba 9", + "description": "Saltar para a aba de conversa ou arquivo 9" + }, "send_message": { "title": "Enviar mensagem", "description": "Enviar a mensagem atual na caixa de entrada" diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index f6a2638c1..e1ea1d1c8 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -392,6 +392,10 @@ "title": "关闭当前标签", "description": "关闭当前会话或文件标签" }, + "reopen_last_closed_tab": { + "title": "重新打开关闭的标签", + "description": "重新打开最近关闭的会话或文件标签" + }, "close_all_file_tabs": { "title": "关闭全部文件标签", "description": "当文件面板处于活动状态时关闭所有打开的文件标签" @@ -404,6 +408,42 @@ "title": "上一个标签页", "description": "切换到上一个会话或文件标签页" }, + "switch_tab_1": { + "title": "切换到第 1 个标签", + "description": "跳转到第 1 个会话或文件标签" + }, + "switch_tab_2": { + "title": "切换到第 2 个标签", + "description": "跳转到第 2 个会话或文件标签" + }, + "switch_tab_3": { + "title": "切换到第 3 个标签", + "description": "跳转到第 3 个会话或文件标签" + }, + "switch_tab_4": { + "title": "切换到第 4 个标签", + "description": "跳转到第 4 个会话或文件标签" + }, + "switch_tab_5": { + "title": "切换到第 5 个标签", + "description": "跳转到第 5 个会话或文件标签" + }, + "switch_tab_6": { + "title": "切换到第 6 个标签", + "description": "跳转到第 6 个会话或文件标签" + }, + "switch_tab_7": { + "title": "切换到第 7 个标签", + "description": "跳转到第 7 个会话或文件标签" + }, + "switch_tab_8": { + "title": "切换到第 8 个标签", + "description": "跳转到第 8 个会话或文件标签" + }, + "switch_tab_9": { + "title": "切换到第 9 个标签", + "description": "跳转到第 9 个会话或文件标签" + }, "send_message": { "title": "发送消息", "description": "在输入框中发送当前消息" diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 13e7b6eae..f8b8f4698 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -392,6 +392,10 @@ "title": "關閉目前分頁", "description": "關閉目前會話或檔案分頁" }, + "reopen_last_closed_tab": { + "title": "重新開啟關閉的標籤", + "description": "重新開啟最近關閉的會話或檔案標籤" + }, "close_all_file_tabs": { "title": "關閉全部檔案分頁", "description": "當檔案面板處於作用中狀態時關閉所有開啟的檔案分頁" @@ -404,6 +408,42 @@ "title": "上一個標籤頁", "description": "切換到上一個會話或檔案標籤頁" }, + "switch_tab_1": { + "title": "切換到第 1 個標籤", + "description": "跳到第 1 個會話或檔案標籤" + }, + "switch_tab_2": { + "title": "切換到第 2 個標籤", + "description": "跳到第 2 個會話或檔案標籤" + }, + "switch_tab_3": { + "title": "切換到第 3 個標籤", + "description": "跳到第 3 個會話或檔案標籤" + }, + "switch_tab_4": { + "title": "切換到第 4 個標籤", + "description": "跳到第 4 個會話或檔案標籤" + }, + "switch_tab_5": { + "title": "切換到第 5 個標籤", + "description": "跳到第 5 個會話或檔案標籤" + }, + "switch_tab_6": { + "title": "切換到第 6 個標籤", + "description": "跳到第 6 個會話或檔案標籤" + }, + "switch_tab_7": { + "title": "切換到第 7 個標籤", + "description": "跳到第 7 個會話或檔案標籤" + }, + "switch_tab_8": { + "title": "切換到第 8 個標籤", + "description": "跳到第 8 個會話或檔案標籤" + }, + "switch_tab_9": { + "title": "切換到第 9 個標籤", + "description": "跳到第 9 個會話或檔案標籤" + }, "send_message": { "title": "傳送訊息", "description": "在輸入框中傳送目前的訊息" diff --git a/src/lib/closed-tab-stack.test.ts b/src/lib/closed-tab-stack.test.ts new file mode 100644 index 000000000..f111be8a1 --- /dev/null +++ b/src/lib/closed-tab-stack.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it } from "vitest" + +import { + CLOSED_TAB_STACK_LIMIT, + peekClosedTab, + popClosedTab, + pushClosedTab, + resetClosedTabStackForTests, + snapshotConversationTab, + snapshotFileTab, +} from "./closed-tab-stack" + +afterEach(() => { + resetClosedTabStackForTests() +}) + +describe("closed tab stack", () => { + it("restores the most recently closed tab first", () => { + pushClosedTab( + snapshotConversationTab({ + folderId: 1, + conversationId: 10, + agentType: "claude_code", + title: "first", + isPinned: false, + }) + ) + pushClosedTab( + snapshotConversationTab({ + folderId: 1, + conversationId: 11, + agentType: "codex", + title: "second", + isPinned: true, + }) + ) + expect(peekClosedTab()?.kind).toBe("conversation") + expect(popClosedTab()).toMatchObject({ + conversationId: 11, + title: "second", + }) + expect(popClosedTab()).toMatchObject({ conversationId: 10, title: "first" }) + expect(popClosedTab()).toBeNull() + }) + + it("drops the oldest entry past the browser-like cap", () => { + for (let i = 0; i < CLOSED_TAB_STACK_LIMIT + 3; i += 1) { + pushClosedTab( + snapshotConversationTab({ + folderId: 1, + conversationId: i, + agentType: "grok", + title: `t${i}`, + isPinned: false, + }) + ) + } + const first = popClosedTab() + expect(first).toMatchObject({ conversationId: CLOSED_TAB_STACK_LIMIT + 2 }) + let oldestKept: ReturnType = null + while (true) { + const next = popClosedTab() + if (!next) break + oldestKept = next + } + expect(oldestKept).toMatchObject({ conversationId: 3 }) + }) + + it("skips a file tab with no path", () => { + expect(snapshotFileTab({ path: null, folderId: 1 })).toBeNull() + expect(snapshotFileTab({ path: "/repo/a.ts", folderId: 2 })).toEqual({ + kind: "file", + path: "/repo/a.ts", + folderId: 2, + }) + }) +}) diff --git a/src/lib/closed-tab-stack.ts b/src/lib/closed-tab-stack.ts new file mode 100644 index 000000000..80613e0b9 --- /dev/null +++ b/src/lib/closed-tab-stack.ts @@ -0,0 +1,69 @@ +import type { AgentType } from "@/lib/types" + +export const CLOSED_TAB_STACK_LIMIT = 20 + +export type ClosedConversationTab = { + kind: "conversation" + folderId: number + conversationId: number | null + agentType: AgentType + title: string + workingDir?: string + isPinned: boolean +} + +export type ClosedFileTab = { + kind: "file" + path: string + folderId: number | null +} + +export type ClosedWorkspaceTab = ClosedConversationTab | ClosedFileTab + +let stack: ClosedWorkspaceTab[] = [] + +export function pushClosedTab(tab: ClosedWorkspaceTab): void { + stack.push(tab) + if (stack.length > CLOSED_TAB_STACK_LIMIT) { + stack = stack.slice(-CLOSED_TAB_STACK_LIMIT) + } +} + +export function popClosedTab(): ClosedWorkspaceTab | null { + return stack.pop() ?? null +} + +export function peekClosedTab(): ClosedWorkspaceTab | null { + return stack.at(-1) ?? null +} + +export function resetClosedTabStackForTests(): void { + stack = [] +} + +export function snapshotConversationTab(tab: { + folderId: number + conversationId: number | null + agentType: AgentType + title: string + workingDir?: string + isPinned: boolean +}): ClosedConversationTab { + return { + kind: "conversation", + folderId: tab.folderId, + conversationId: tab.conversationId, + agentType: tab.agentType, + title: tab.title, + workingDir: tab.workingDir, + isPinned: tab.isPinned, + } +} + +export function snapshotFileTab(tab: { + path: string | null + folderId: number | null +}): ClosedFileTab | null { + if (!tab.path) return null + return { kind: "file", path: tab.path, folderId: tab.folderId } +} diff --git a/src/lib/keyboard-shortcuts.test.ts b/src/lib/keyboard-shortcuts.test.ts index d32d86949..61ddb44bc 100644 --- a/src/lib/keyboard-shortcuts.test.ts +++ b/src/lib/keyboard-shortcuts.test.ts @@ -2,8 +2,11 @@ import { describe, expect, it } from "vitest" import { DEFAULT_SHORTCUTS, + NUMBERED_TAB_ACTION_IDS, SHORTCUT_DEFINITIONS, matchShortcutEvent, + numberedTabIndexFromEvent, + pickNumberedTabId, shortcutFromKeyboardEvent, } from "./keyboard-shortcuts" @@ -62,6 +65,62 @@ describe("tab cycling shortcuts", () => { }) }) +describe("numbered tab shortcuts", () => { + it("registers Ctrl/Cmd+1 through 9 as switch_tab_N", () => { + const ids = SHORTCUT_DEFINITIONS.map((definition) => definition.id) + for (const [index, actionId] of NUMBERED_TAB_ACTION_IDS.entries()) { + expect(ids).toContain(actionId) + expect(DEFAULT_SHORTCUTS[actionId]).toBe(`mod+${index + 1}`) + } + }) + + it("maps Ctrl+1 to the first tab and ignores a missing ninth tab", () => { + const tabs = ["conv-a", "conv-b", "conv-c"] + expect(pickNumberedTabId(tabs, 0)).toBe("conv-a") + expect(pickNumberedTabId(tabs, 2)).toBe("conv-c") + expect(pickNumberedTabId(tabs, 8)).toBeNull() + expect(pickNumberedTabId([], 0)).toBeNull() + }) + + it("resolves Ctrl+2 against the default numbered-tab bindings", () => { + expect( + numberedTabIndexFromEvent( + keyEvent("2", { ctrlKey: true }), + DEFAULT_SHORTCUTS + ) + ).toBe(1) + expect( + numberedTabIndexFromEvent(keyEvent("2"), DEFAULT_SHORTCUTS) + ).toBeNull() + expect( + numberedTabIndexFromEvent( + keyEvent("Tab", { ctrlKey: true }), + DEFAULT_SHORTCUTS + ) + ).toBeNull() + }) +}) + +describe("reopen last closed tab", () => { + it("defaults to Ctrl/Cmd+Shift+T", () => { + const ids = SHORTCUT_DEFINITIONS.map((definition) => definition.id) + expect(ids).toContain("reopen_last_closed_tab") + expect(DEFAULT_SHORTCUTS.reopen_last_closed_tab).toBe("mod+shift+t") + expect( + matchShortcutEvent( + keyEvent("t", { ctrlKey: true, shiftKey: true }), + DEFAULT_SHORTCUTS.reopen_last_closed_tab + ) + ).toBe(true) + expect( + matchShortcutEvent( + keyEvent("t", { ctrlKey: true }), + DEFAULT_SHORTCUTS.reopen_last_closed_tab + ) + ).toBe(false) + }) +}) + describe("alt combinations use event.code", () => { // macOS 上 ⌥S 报的 event.key 是 "ß",不是 "s"。不看 event.code 的话,任何 // 含 alt 的组合在 macOS 上都按不出来 —— 自定义样式的逃生舱正是这样一条。 diff --git a/src/lib/keyboard-shortcuts.ts b/src/lib/keyboard-shortcuts.ts index 342995d88..7d6483a2a 100644 --- a/src/lib/keyboard-shortcuts.ts +++ b/src/lib/keyboard-shortcuts.ts @@ -9,9 +9,19 @@ export type ShortcutActionId = | "open_folder" | "open_settings" | "close_current_tab" + | "reopen_last_closed_tab" | "close_all_file_tabs" | "next_tab" | "prev_tab" + | "switch_tab_1" + | "switch_tab_2" + | "switch_tab_3" + | "switch_tab_4" + | "switch_tab_5" + | "switch_tab_6" + | "switch_tab_7" + | "switch_tab_8" + | "switch_tab_9" | "send_message" | "newline_in_message" | "toggle_custom_style" @@ -51,6 +61,9 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [ { id: "close_current_tab", }, + { + id: "reopen_last_closed_tab", + }, { id: "close_all_file_tabs", }, @@ -60,6 +73,15 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [ { id: "prev_tab", }, + { id: "switch_tab_1" }, + { id: "switch_tab_2" }, + { id: "switch_tab_3" }, + { id: "switch_tab_4" }, + { id: "switch_tab_5" }, + { id: "switch_tab_6" }, + { id: "switch_tab_7" }, + { id: "switch_tab_8" }, + { id: "switch_tab_9" }, { id: "send_message", }, @@ -90,9 +112,19 @@ export const DEFAULT_SHORTCUTS: ShortcutSettings = { open_folder: "mod+o", open_settings: "mod+,", close_current_tab: "mod+w", + reopen_last_closed_tab: "mod+shift+t", close_all_file_tabs: "mod+shift+w", next_tab: "mod+tab", prev_tab: "mod+shift+tab", + switch_tab_1: "mod+1", + switch_tab_2: "mod+2", + switch_tab_3: "mod+3", + switch_tab_4: "mod+4", + switch_tab_5: "mod+5", + switch_tab_6: "mod+6", + switch_tab_7: "mod+7", + switch_tab_8: "mod+8", + switch_tab_9: "mod+9", send_message: "enter", newline_in_message: "shift+enter", // 自定义样式的逃生舱:用户把界面改到不可用时,这一路必须仍然按得动,所以选一个 @@ -306,6 +338,42 @@ export function shortcutFromKeyboardEvent( return parts.join("+") } +export const NUMBERED_TAB_ACTION_IDS = [ + "switch_tab_1", + "switch_tab_2", + "switch_tab_3", + "switch_tab_4", + "switch_tab_5", + "switch_tab_6", + "switch_tab_7", + "switch_tab_8", + "switch_tab_9", +] as const satisfies readonly ShortcutActionId[] + +/** 0-based index into the visible tab strip, or null when that tab does not exist. */ +export function pickNumberedTabId( + tabIds: readonly string[], + index: number +): string | null { + if (!Number.isInteger(index) || index < 0 || index >= tabIds.length) { + return null + } + return tabIds[index] ?? null +} + +export function numberedTabIndexFromEvent( + event: ShortcutEventLike, + shortcuts: ShortcutSettings +): number | null { + for (let index = 0; index < NUMBERED_TAB_ACTION_IDS.length; index += 1) { + const actionId = NUMBERED_TAB_ACTION_IDS[index] + if (matchShortcutEvent(event, shortcuts[actionId])) { + return index + } + } + return null +} + export function matchShortcutEvent( event: ShortcutEventLike, shortcut: string diff --git a/src/stores/tab-store.ts b/src/stores/tab-store.ts index 8c2513d62..32cbc4aac 100644 --- a/src/stores/tab-store.ts +++ b/src/stores/tab-store.ts @@ -35,6 +35,7 @@ import { moveMessageInputDraft, sweepOrphanDraftKeys, } from "@/lib/message-input-draft" +import { pushClosedTab, snapshotConversationTab } from "@/lib/closed-tab-stack" import type { AgentType, ConversationChange, @@ -1133,6 +1134,7 @@ export const useTabStore = create()((set, get) => ({ const index = prevState.rawTabs.findIndex((t) => t.id === tabId) if (index >= 0) { const closingTab = prevState.rawTabs[index] + pushClosedTab(snapshotConversationTab(closingTab)) const next = prevState.rawTabs.filter((t) => t.id !== tabId) // A closing draft's composer text is scoped to that tab's key. Drop it — // unless this close spawns the replacement draft, which continues the same @@ -1231,6 +1233,12 @@ export const useTabStore = create()((set, get) => ({ focusTab(tabId) return } + const keepIds = new Set(keep.map((tab) => tab.id)) + for (const tab of prevState.rawTabs) { + if (!keepIds.has(tab.id)) { + pushClosedTab(snapshotConversationTab(tab)) + } + } set({ rawTabs: keep, activeTabId: tabId }) recomputeTabs() }, @@ -1241,6 +1249,9 @@ export const useTabStore = create()((set, get) => ({ if (prevState.rawTabs.length === 0 && prevState.activeTabId == null) { return } + for (const tab of prevState.rawTabs) { + pushClosedTab(snapshotConversationTab(tab)) + } set({ rawTabs: [], activeTabId: null }) recomputeTabs() return @@ -1252,6 +1263,9 @@ export const useTabStore = create()((set, get) => ({ prevState.rawTabs.find((t) => t.id === prevState.activeTabId) ?? prevState.rawTabs[0] const replacementTab = makeReplacementDraftTab(seedTab) + for (const tab of prevState.rawTabs) { + pushClosedTab(snapshotConversationTab(tab)) + } set({ rawTabs: [replacementTab], activeTabId: replacementTab.id }) recomputeTabs() runtime.activateConversationPane() @@ -1261,6 +1275,11 @@ export const useTabStore = create()((set, get) => ({ const prevState = get() const remaining = prevState.rawTabs.filter((t) => t.folderId !== folderId) if (remaining.length === prevState.rawTabs.length) return + for (const tab of prevState.rawTabs) { + if (tab.folderId === folderId) { + pushClosedTab(snapshotConversationTab(tab)) + } + } const currentActive = prevState.activeTabId const stillActive =