diff --git a/CHANGELOG.md b/CHANGELOG.md index 20cef21..062685f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,19 @@ Human-curated release notes. Earlier 0.9.x notes also live on the [GitHub releases](https://github.com/benjsmith/switchbay/releases) page. +## 2026-09-13 — v0.12.18 + +**Migration:** none. **Breaking:** none. After pull, run +`make refresh BUILD=1` (Editor vault-source Close). No new VSIX. + +### Added + +- **Close on vault-source Editor tabs.** The Editor sub-toolbar right + cluster (Graph / Slideshow / … / Save) gains **Close** for ephemeral + vault `.extracted.md` user tabs — removes the tab from the strip and + activates a neighbor. Uses `/api/tabs/vault-doc/remove` + + `tabstore.remove_vault_doc_tab`. Core Editor unchanged. + ## 2026-09-13 — v0.12.17 **Migration:** none. **Breaking:** none. After pull, run diff --git a/frontend/package.json b/frontend/package.json index 335631c..2ad60b7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "switchbay-frontend", "private": true, - "version": "0.12.17", + "version": "0.12.18", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8af0d26..e457e9d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -527,6 +527,37 @@ export default function App() { } catch { /* daemon down */ } }, []); + /** Close a vault-source Editor tab (user markdown with pinned path). + * Optimistic strip update + neighbor activation; hello broadcast + * reconciles. Core Editor is never closable via this path. */ + const closeTab = useCallback(async (tabId: string) => { + try { + const r = await fetch("/api/tabs/vault-doc/remove", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tab_id: tabId }), + }); + if (!r.ok) return; + let neighbor: string | null = null; + setMode((cur) => { + const idx = cur.tabs.findIndex((t) => t.id === tabId); + if (idx < 0) return cur; + const tab = cur.tabs[idx]!; + if (tab.source !== "user" || tab.kind !== "markdown") return cur; + const path = (tab.payload as { path?: unknown } | undefined)?.path; + if (typeof path !== "string" || !path.startsWith("vault/")) return cur; + neighbor = + cur.tabs[idx + 1]?.id + ?? cur.tabs[idx - 1]?.id + ?? cur.tabs.find((t) => t.id !== tabId)?.id + ?? null; + return { ...cur, tabs: cur.tabs.filter((t) => t.id !== tabId) }; + }); + setActiveTab((cur) => (cur === tabId ? neighbor : cur)); + setZenSurface((cur) => (cur === tabId ? neighbor : cur)); + } catch { /* daemon down — hello will resync if it recovers */ } + }, []); + /** Scope-toggle from the tab strip: user tabs flip between * workspace-wide and scoped-to-the-focused-thread. The hello * broadcast carries the updated mode back to every client. */ @@ -2341,8 +2372,14 @@ export default function App() { }), []); const tabsValue = useMemo( - () => ({ tabs: mode.tabs, activeId: activeTab, setActive: setActiveTab, switchToKind }), - [mode, activeTab, switchToKind], + () => ({ + tabs: mode.tabs, + activeId: activeTab, + setActive: setActiveTab, + switchToKind, + closeTab, + }), + [mode, activeTab, switchToKind, closeTab], ); // ⌘K palette: wiki pages join the fuzzy list (D5 + Zen ruling — diff --git a/frontend/src/center/TabsContext.tsx b/frontend/src/center/TabsContext.tsx index 29fbfab..f33c88e 100644 --- a/frontend/src/center/TabsContext.tsx +++ b/frontend/src/center/TabsContext.tsx @@ -11,6 +11,12 @@ type Ctx = { * Returns true if a tab was found. */ switchToKind: (kind: string) => boolean; + /** + * Close a closable user tab (vault-source Editor tabs today). + * Removes it from the strip and activates a sensible neighbor. + * No-op when the id is unknown / not closable. + */ + closeTab: (tabId: string) => void; }; const TabsContext = createContext(null); diff --git a/frontend/src/widgets/editor/EditorTab.tsx b/frontend/src/widgets/editor/EditorTab.tsx index a87af22..6aa3b92 100644 --- a/frontend/src/widgets/editor/EditorTab.tsx +++ b/frontend/src/widgets/editor/EditorTab.tsx @@ -50,7 +50,7 @@ type LoadState = export default function EditorTab({ tab }: { tab?: TabSpec } = {}) { const { selection, setSelection } = useSelection(); - const { switchToKind, tabs } = useTabs(); + const { switchToKind, tabs, closeTab } = useTabs(); const [state, setState] = useState({ kind: "idle" }); const [graphData, setGraphData] = useState(null); const sourceRef = useRef(null); @@ -63,6 +63,16 @@ export default function EditorTab({ tab }: { tab?: TabSpec } = {}) { ? String(tab.payload.path) : ""; const pinnedPath = payloadPath.startsWith("vault/") ? payloadPath : null; + // Ephemeral vault `.extracted.md` user tabs (and any other user + // markdown tab pinned to a vault path) get a Close control in the + // right toolbar cluster. The core Editor never does. + const isClosableVaultTab = Boolean( + tab + && tab.source === "user" + && tab.kind === "markdown" + && pinnedPath + && tab.id, + ); const path = pinnedPath || (selection?.kind === "page" ? selection.path : null); const pageId = pinnedPath || (selection?.kind === "page" ? selection.id : null); @@ -507,6 +517,17 @@ export default function EditorTab({ tab }: { tab?: TabSpec } = {}) { > {isSaving ? "Saving…" : "Save"} + {isClosableVaultTab && tab && ( + + )} {isMarkdownPage && String(properties.kind || "").toLowerCase() === "deck" && (
diff --git a/pyproject.toml b/pyproject.toml index 8e894bc..1a9403a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "switchbay" -version = "0.12.17" +version = "0.12.18" description = "Local single-user workbench over knowledge bases — a driven second brain." requires-python = ">=3.11" authors = [{ name = "Benjamin Smith" }] diff --git a/src/switchbay/__init__.py b/src/switchbay/__init__.py index e80e9fd..6db2827 100644 --- a/src/switchbay/__init__.py +++ b/src/switchbay/__init__.py @@ -4,7 +4,7 @@ # checker both read this, so drift here makes a current install look # stale and offers an "update" to a release it is already past. # tests/unit/test_version_sync.py fails the build if the two diverge. -__version__ = "0.12.17" +__version__ = "0.12.18" # Use the OS certificate store for HTTPS (corporate TLS proxies, custom # CAs). Must run before any aiohttp ClientSession creates an SSL context diff --git a/src/switchbay/daemon.py b/src/switchbay/daemon.py index e1593dc..48d249c 100644 --- a/src/switchbay/daemon.py +++ b/src/switchbay/daemon.py @@ -6680,6 +6680,29 @@ async def handle_tab_vault_doc_add(request: web.Request) -> web.Response: return web.json_response({"ok": True, "tab": tab}) +async def handle_tab_vault_doc_remove(request: web.Request) -> web.Response: + """Close a dedicated vault-source Editor tab. Body: {tab_id}. + Only removes user markdown tabs created by /api/tabs/vault-doc — + the core Editor is untouched.""" + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"error": "invalid json"}, status=400) + tab_id = str(body.get("tab_id") or "").strip() + if not tab_id: + return web.json_response({"error": "tab_id required"}, status=400) + workspace: Path = request.app["workspace"] + removed = await asyncio.to_thread( + tabstore.remove_vault_doc_tab, workspace, tab_id, + ) + if not removed: + return web.json_response( + {"error": "no such vault-source tab"}, status=404, + ) + await _broadcast(request.app, _hello_payload(request.app)) + return web.json_response({"ok": True, "removed": tab_id}) + + async def handle_shell_detect(request: web.Request) -> web.Response: """Router support for the rail's interpretation chip: does this input look like a shell command? (PATH lookups off-loop.)""" @@ -16539,6 +16562,7 @@ def build_app(workspace: Path) -> web.Application: app.router.add_post("/api/tabs/terminal", handle_tab_terminal_add) app.router.add_post("/api/tabs/terminal/remove", handle_tab_terminal_remove) app.router.add_post("/api/tabs/vault-doc", handle_tab_vault_doc_add) + app.router.add_post("/api/tabs/vault-doc/remove", handle_tab_vault_doc_remove) app.router.add_get("/ws", handle_ws) # Catch-all LAST: serves the built SPA + PWA assets (manifest, icons) # for any non-API GET. aiohttp matches in registration order, so the diff --git a/uv.lock b/uv.lock index 0613314..a1635c6 100644 --- a/uv.lock +++ b/uv.lock @@ -2515,7 +2515,7 @@ wheels = [ [[package]] name = "switchbay" -version = "0.12.17" +version = "0.12.18" source = { virtual = "." } dependencies = [ { name = "aiohttp" },