From 8d5fa782ea027eced056576a3ed4796b2a1a10cb Mon Sep 17 00:00:00 2001 From: Ben Holmes Date: Sat, 19 Sep 2026 08:52:09 -0400 Subject: [PATCH 1/3] Add tab and file actions --- apps/desktop/src/components/DocumentTabs.tsx | 4 + apps/desktop/src/components/FileInfoBar.tsx | 28 ++ apps/desktop/src/store/actions.ts | 1 + apps/desktop/src/store/tabActions.test.ts | 44 ++++ apps/desktop/src/store/tabActions.ts | 29 ++- .../ui/src/components/tabbar/TabStrip.tsx | 240 +++++++++++------- 6 files changed, 249 insertions(+), 97 deletions(-) diff --git a/apps/desktop/src/components/DocumentTabs.tsx b/apps/desktop/src/components/DocumentTabs.tsx index 252bc9f9..96220d5e 100644 --- a/apps/desktop/src/components/DocumentTabs.tsx +++ b/apps/desktop/src/components/DocumentTabs.tsx @@ -5,7 +5,9 @@ import { isChangelogPath } from "../lib/changelogNote"; import { fileStem, relativeWorkspacePath } from "../lib/filePath"; import { activateTab, + closeOtherTabs, closeTab, + closeTabsToLeft, renameCurrentMarkdownFile, reorderTab, } from "../store/actions"; @@ -54,6 +56,8 @@ export function DocumentTabs({ activeTabId={tabs.activeTabId} onActivate={(id) => void activateTab(id)} onClose={(id) => void closeTab(id)} + onCloseOthers={(id) => void closeOtherTabs(id)} + onCloseLeft={(id) => void closeTabsToLeft(id)} onReorder={reorderTab} onNewTab={onNewTab} newTabTitle={newTabTitle} diff --git a/apps/desktop/src/components/FileInfoBar.tsx b/apps/desktop/src/components/FileInfoBar.tsx index f27907f6..d4303132 100644 --- a/apps/desktop/src/components/FileInfoBar.tsx +++ b/apps/desktop/src/components/FileInfoBar.tsx @@ -16,6 +16,7 @@ import MingcuteArrowLeftLine from "~icons/mingcute/arrow-left-line"; import MingcuteArrowRightLine from "~icons/mingcute/arrow-right-line"; import MingcuteCodeLine from "~icons/mingcute/code-line"; import MingcuteCopy2Line from "~icons/mingcute/copy-2-line"; +import MingcuteDeleteLine from "~icons/mingcute/delete-line"; import MingcuteExternalLinkLine from "~icons/mingcute/external-link-line"; import MingcuteFolderOpenLine from "~icons/mingcute/folder-open-line"; import MingcuteMore2Line from "~icons/mingcute/more-2-line"; @@ -25,6 +26,7 @@ import type { AgentClient } from "../desktopApi/types"; import { isChangelogPath } from "../lib/changelogNote"; import { copyText } from "../lib/clipboard"; import { + basename, dirname, hasHtmlExtension, hasMarkdownExtension, @@ -37,6 +39,7 @@ import { import { useCompactWindow } from "../lib/layout"; import { revealFileLabel } from "../lib/revealFile"; import { + deleteSidebarItems, goBack, goForward, openPathInDefaultApp, @@ -48,6 +51,7 @@ import { import { useHistoryNav } from "../store/hooks"; import { currentPathStore, + isInWorkspace, reviewThreadsStore, titleGenerationPreviewStore, viewerStore, @@ -153,6 +157,9 @@ export function FileInfoBar({ {(compact || actionPath) && ( Copy file path + {canDelete ? ( + <> + + + + Delete + + + + ) : null} )} diff --git a/apps/desktop/src/store/actions.ts b/apps/desktop/src/store/actions.ts index c8dbcb20..69b4f6e8 100644 --- a/apps/desktop/src/store/actions.ts +++ b/apps/desktop/src/store/actions.ts @@ -753,6 +753,7 @@ export const { closeTab, reopenClosedTab, closeOtherTabs, + closeTabsToLeft, closeAllTabs, closeActiveTab, activateAdjacentTab, diff --git a/apps/desktop/src/store/tabActions.test.ts b/apps/desktop/src/store/tabActions.test.ts index d4732a05..781b08e7 100644 --- a/apps/desktop/src/store/tabActions.test.ts +++ b/apps/desktop/src/store/tabActions.test.ts @@ -575,6 +575,50 @@ describe("desktop tabs", () => { expect(viewerStore.get().currentPath).toBe("/workspace/c.md"); }); + it("closes other tabs around a chosen tab", async () => { + const api = createDesktopApi(); + api.pathExists.mockResolvedValue(true); + api.readFileText.mockImplementation( + async (path: string) => `content:${path}`, + ); + const { appStore, closeOtherTabs, loadPath, openTabForPath, viewerStore } = + await loadStoreActions(api); + + await loadPath("/workspace/a.md"); + await openTabForPath("/workspace/b.md"); + const kept = appStore.get().tabs.activeTabId; + await openTabForPath("/workspace/c.md"); + if (!kept) throw new Error("expected a tab"); + await closeOtherTabs(kept); + + expect(appStore.get().tabs.order).toEqual([kept]); + expect(viewerStore.get().currentPath).toBe("/workspace/b.md"); + }); + + it("closes only tabs to the left of a chosen tab", async () => { + const api = createDesktopApi(); + api.pathExists.mockResolvedValue(true); + api.readFileText.mockImplementation( + async (path: string) => `content:${path}`, + ); + const { appStore, closeTabsToLeft, loadPath, openTabForPath, viewerStore } = + await loadStoreActions(api); + + await loadPath("/workspace/a.md"); + await openTabForPath("/workspace/b.md"); + const target = appStore.get().tabs.activeTabId; + await openTabForPath("/workspace/c.md"); + if (!target) throw new Error("expected a tab"); + await closeTabsToLeft(target); + + const tabs = appStore.get().tabs; + expect(tabs.order.map((id) => tabs.byId[id].path)).toEqual([ + "/workspace/b.md", + "/workspace/c.md", + ]); + expect(viewerStore.get().currentPath).toBe("/workspace/c.md"); + }); + it("closes every tab and empties the editor", async () => { const api = createDesktopApi(); api.pathExists.mockResolvedValue(true); diff --git a/apps/desktop/src/store/tabActions.ts b/apps/desktop/src/store/tabActions.ts index 70f22ea9..1e24871e 100644 --- a/apps/desktop/src/store/tabActions.ts +++ b/apps/desktop/src/store/tabActions.ts @@ -200,12 +200,30 @@ export function createTabActions({ return reopenClosedTabInOrder(getWorkspaceRequest()); } - async function closeOtherTabs() { - const { order, activeTabId } = tabsStore.get(); - if (!activeTabId) return; - for (const id of order) { - if (id !== activeTabId) await closeTab(id); + async function closeOtherTabs(keepId?: TabId) { + let tabs = tabsStore.get(); + const keep = keepId ?? tabs.activeTabId; + if (!keep || !tabs.order.includes(keep)) return; + if (tabs.activeTabId !== keep) { + await activateTab(keep); + if (tabsStore.get().activeTabId !== keep) return; + tabs = tabsStore.get(); + } + for (const id of tabs.order) { + if (id !== keep) await closeTab(id); + } + } + + async function closeTabsToLeft(id: TabId) { + const tabs = tabsStore.get(); + const index = tabs.order.indexOf(id); + if (index <= 0) return; + const closing = tabs.order.slice(0, index); + if (tabs.activeTabId && closing.includes(tabs.activeTabId)) { + await activateTab(id); + if (tabsStore.get().activeTabId !== id) return; } + for (const closingId of closing) await closeTab(closingId); } /** Close every tab and clear the editor. */ @@ -247,6 +265,7 @@ export function createTabActions({ closeTab, reopenClosedTab, closeOtherTabs, + closeTabsToLeft, closeAllTabs, closeActiveTab, activateAdjacentTab, diff --git a/packages/ui/src/components/tabbar/TabStrip.tsx b/packages/ui/src/components/tabbar/TabStrip.tsx index d8426e79..1bdfd5f3 100644 --- a/packages/ui/src/components/tabbar/TabStrip.tsx +++ b/packages/ui/src/components/tabbar/TabStrip.tsx @@ -1,3 +1,4 @@ +import { ContextMenu } from "@base-ui/react/context-menu"; import { type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, @@ -39,6 +40,8 @@ export type TabStripProps = { collapseTargetRef?: RefObject; onActivate: (id: string) => void; onClose: (id: string) => void; + onCloseOthers?: (id: string) => void; + onCloseLeft?: (id: string) => void; onReorder?: (id: string, toIndex: number) => void; onNewTab?: () => void; newTabTitle?: string; @@ -57,6 +60,8 @@ export function TabStrip({ collapseTargetRef, onActivate, onClose, + onCloseOthers, + onCloseLeft, onReorder, onNewTab, newTabTitle = "New tab", @@ -217,103 +222,114 @@ export function TabStrip({ const active = tab.id === activeTabId; const editing = editingId === tab.id; return ( -
- {index === 0 && !active && showStartDivider ? ( -
+ + onCloseOthers(tab.id) : undefined + } + onCloseLeft={ + onCloseLeft ? () => onCloseLeft(tab.id) : undefined + } + disableOthers={tabs.length === 1} + disableLeft={index === 0} + /> + ); })} @@ -339,6 +355,46 @@ export function TabStrip({ ); } +function TabMenu({ + onCloseOthers, + onCloseLeft, + disableOthers, + disableLeft, +}: { + onCloseOthers?: () => void; + onCloseLeft?: () => void; + disableOthers: boolean; + disableLeft: boolean; +}) { + if (!onCloseOthers && !onCloseLeft) return null; + return ( + + + + {onCloseOthers ? ( + + Close other tabs + + ) : null} + {onCloseLeft ? ( + + Close tabs to the left + + ) : null} + + + + ); +} + function TabOutline({ flushStart }: { flushStart: boolean }) { return (
Date: Sat, 19 Sep 2026 08:52:24 -0400 Subject: [PATCH 2/3] Add changelog entry --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e587e92a..ede13b60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com). ### Added +- Tabs now have right-click actions to close other tabs or tabs to the left, and open files can be deleted from the file bar overflow menu. [#294](https://github.com/bholmesdev/hubble.md/pull/294) + ### Changed ### Fixed From e90dc0d3bcfb4cf7e8fc0be066b91621b1f41744 Mon Sep 17 00:00:00 2001 From: Ben Holmes Date: Sat, 19 Sep 2026 08:52:51 -0400 Subject: [PATCH 3/3] Remove delete shortcut hint --- apps/desktop/src/components/FileInfoBar.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/desktop/src/components/FileInfoBar.tsx b/apps/desktop/src/components/FileInfoBar.tsx index d4303132..43e39f6e 100644 --- a/apps/desktop/src/components/FileInfoBar.tsx +++ b/apps/desktop/src/components/FileInfoBar.tsx @@ -369,7 +369,6 @@ function ActionsMenu({ > Delete - ) : null}