From be073a8f3926f321594250115ed31b2630a98db5 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Fri, 11 Sep 2026 14:15:27 +0200 Subject: [PATCH] feat(workspaces): add workspace sessions and management Introduce workspace support to group repositories and store per-workspace open tabs and the last active repository. Integrate a workspace picker into the repository tab bar and add workspace controls to the dashboard to create, edit, switch and delete workspaces. Persist and migrate workspace preferences robustly and include tests for migration, corruption and edge cases. - Persist per-workspace sessions and active repo to localStorage - Add workspace picker in tabs and dashboard management hooks - Include migration/validation logic and unit tests for edge cases --- scripts/workspaces.test.mjs | 45 +++++++++ src/App.svelte | 75 +++++++++++++- src/lib/RepoTabs.svelte | 15 ++- src/lib/components/RepositoryDashboard.svelte | 97 ++++++++----------- src/lib/workspaces.ts | 42 ++++++++ 5 files changed, 216 insertions(+), 58 deletions(-) create mode 100644 scripts/workspaces.test.mjs create mode 100644 src/lib/workspaces.ts diff --git a/scripts/workspaces.test.mjs b/scripts/workspaces.test.mjs new file mode 100644 index 0000000..f1a2051 --- /dev/null +++ b/scripts/workspaces.test.mjs @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import ts from 'typescript'; + +const source = readFileSync(new URL('../src/lib/workspaces.ts', import.meta.url), 'utf8'); +const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 } }).outputText; +const { readWorkspaces, WORKSPACES_KEY } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`); +const storage = values => ({ getItem: key => values[key] ?? null }); + +test('migrates dashboard assignments without copying unrelated tabs into a workspace', () => { + const result = readWorkspaces(storage({ 'gitty.dashboard.v1': JSON.stringify({ + workspaces: [{ id: 'workspace-a', name: 'A' }, { id: 'workspace-b', name: 'B' }], + assignments: { '/a': 'workspace-a', '/b': 'workspace-b', '/closed': 'workspace-a' }, + }) }), ['/a', '/b', '/outside']); + assert.deepEqual(result.workspaces[0], { id: 'workspace-a', name: 'A', repositories: ['/a', '/closed'], openPaths: ['/a'], activePath: '/a' }); + assert.deepEqual(result.workspaces[1].openPaths, ['/b']); + assert.deepEqual(result.defaultSession.openPaths, ['/a', '/b', '/outside']); +}); + +test('restores independent tab order and last active repository', () => { + const saved = { selectedId: 'workspace-a', workspaces: [ + { id: 'workspace-a', name: 'A', repositories: ['/a', '/b'], openPaths: ['/b', '/a'], activePath: '/a' }, + { id: 'workspace-b', name: 'B', repositories: ['/b'], openPaths: [], activePath: '' }, + ], defaultSession: { openPaths: ['/outside'], activePath: '/outside' } }; + assert.deepEqual(readWorkspaces(storage({ [WORKSPACES_KEY]: JSON.stringify(saved) }), []), saved); +}); + +test('drops invalid memberships and stale active paths from saved sessions', () => { + const saved = { selectedId: 'deleted', workspaces: [null, { id: 'workspace-a', name: 'A', + repositories: ['/a', '/a', null], openPaths: ['/removed', '/a', 3], activePath: '/removed' }], + defaultSession: { openPaths: ['/outside'], activePath: '/removed' } }; + const result = readWorkspaces(storage({ [WORKSPACES_KEY]: JSON.stringify(saved) }), []); + assert.equal(result.selectedId, ''); + assert.deepEqual(result.workspaces[0].openPaths, ['/a']); + assert.equal(result.workspaces[0].activePath, '/a'); + assert.deepEqual(result.workspaces[0].repositories, ['/a']); + assert.equal(result.defaultSession.activePath, '/outside'); +}); + +test('unavailable or corrupt preferences preserve existing repository tabs', () => { + for (const source of [storage({ [WORKSPACES_KEY]: '{broken' }), { getItem() { throw new Error('Storage unavailable'); } }]) { + assert.deepEqual(readWorkspaces(source, ['/existing']).defaultSession.openPaths, ['/existing']); + } +}); diff --git a/src/App.svelte b/src/App.svelte index a7fd05f..5f8dc94 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -14,6 +14,7 @@ import type { PullRequestBadgeState } from "./lib/pullRequestBadges"; let pullRequestBadges: Record = {}; import IssueCenter from "./lib/components/IssueCenter.svelte"; + import { readWorkspaces, WORKSPACES_KEY, type Workspace, type WorkspaceState } from "./lib/workspaces"; import RepositoryDashboard from "./lib/components/RepositoryDashboard.svelte"; import ReviewCenter from "./lib/components/ReviewCenter.svelte"; import RepoTabs from "./lib/RepoTabs.svelte"; @@ -331,13 +332,15 @@ let reviewConflictRemote = ""; let reviewConflictPushRequested = false; let repoTabs: RepoTab[] = []; + let workspaceState: WorkspaceState = { selectedId: "", workspaces: [], defaultSession: { openPaths: [], activePath: "" } }; + $: workspaceOptions = [{ value: "", label: appLanguage === "de" ? "Alle Repositories" : "All repositories" }, ...workspaceState.workspaces.map(item => ({ value: item.id, label: item.name }))]; let repoTabContextMenu: RepoTabContextMenu | null = null; let recentRepoPaths: string[] = []; let favoriteRepoPaths: string[] = []; // Last-seen branch/ahead/behind/changed for repos that are known (recent/favorites) // but not currently open as a tab — keyed by normalized path (repoKey). let repoStatusCache: Record = {}; - $: dashboardRepos = uniqueRepoPaths([...repoTabs.map(tab => tab.path), ...recentRepoPaths, ...favoriteRepoPaths]) + $: dashboardRepos = uniqueRepoPaths([...repoTabs.map(tab => tab.path), ...recentRepoPaths, ...favoriteRepoPaths, ...workspaceState.workspaces.flatMap(item => item.repositories)]) .map(path => ({ ...repoRowFromPath(path, repoTabs, repoStatusCache), isOpen: repoTabs.some(tab => sameRepoPath(tab.path, path)), known: Boolean(repoStatusCache[repoKey(path)]), @@ -701,6 +704,9 @@ initAnalytics(); loadRepoLists(); + workspaceState = readWorkspaces(localStorage, repoTabs.map(tab => tab.path)); + repoTabs = currentWorkspaceSession().openPaths.map(path => repoRowFromPath(path)); + persistWorkspaces(); void checkForUpdates(); aiSettings = loadAiSettings(); @@ -714,6 +720,7 @@ await closeStartupSplashscreen(); startupReady = true; await receiveStartupRepository(); + if (!activeRepoPath && currentWorkspaceSession().activePath) await openRepo(currentWorkspaceSession().activePath); startBackgroundTimers(); // Remote access can take seconds (offline networks, SSH negotiation, // credential helpers). It must never hold the startup screen hostage. @@ -1672,7 +1679,63 @@ } } + function currentWorkspaceSession() { + return workspaceState.workspaces.find(item => item.id === workspaceState.selectedId) ?? workspaceState.defaultSession; + } + + function persistWorkspaces() { + const session = currentWorkspaceSession(); + session.openPaths = repoTabs.map(tab => tab.path); + session.activePath = session.openPaths.find(path => sameRepoPath(path, activeRepoPath)) + ?? session.openPaths.find(path => sameRepoPath(path, session.activePath)) ?? session.openPaths[0] ?? ""; + workspaceState = { ...workspaceState }; + try { localStorage.setItem(WORKSPACES_KEY, JSON.stringify(workspaceState)); } catch { /* Optional local preferences. */ } + } + + async function switchWorkspace(id: string) { + if (isBusy || id === workspaceState.selectedId) return; + persistWorkspaces(); + repoOpenRequestId += 1; + closeRepoTabContextMenu(); + resetRepositoryState(true); + workspaceState = { ...workspaceState, selectedId: id }; + const session = currentWorkspaceSession(); + repoTabs = session.openPaths.map(path => repoRowFromPath(path)); + activeView = "management"; + persistRepoLists(); + if (session.activePath) await openRepo(session.activePath); + } + + async function saveWorkspace(id: string, name: string, repositories: string[]) { + if (isBusy) return; + persistWorkspaces(); + const existing = workspaceState.workspaces.find(item => item.id === id); + const workspace: Workspace = existing + ? { ...existing, name, repositories, openPaths: existing.openPaths.filter(path => repositories.some(repo => sameRepoPath(repo, path))) } + : { id: `workspace-${crypto.randomUUID()}`, name, repositories, openPaths: [], activePath: "" }; + workspace.activePath = workspace.openPaths.includes(workspace.activePath) ? workspace.activePath : workspace.openPaths[0] ?? ""; + workspaceState = { ...workspaceState, workspaces: [...workspaceState.workspaces.filter(item => item.id !== workspace.id), workspace] }; + if (workspaceState.selectedId === workspace.id) { + repoTabs = repoTabs.filter(tab => repositories.some(path => sameRepoPath(path, tab.path))); + if (!repositories.some(path => sameRepoPath(path, activeRepoPath))) { + repoOpenRequestId += 1; + resetRepositoryState(true); + activeView = "management"; + } + persistRepoLists(); + } else await switchWorkspace(workspace.id); + } + + async function deleteWorkspace() { + if (isBusy || !workspaceState.selectedId) return; + const id = workspaceState.selectedId; + await switchWorkspace(""); + workspaceState = { ...workspaceState, workspaces: workspaceState.workspaces.filter(item => item.id !== id) }; + persistWorkspaces(); + } + function persistRepoLists() { + persistWorkspaces(); try { localStorage.setItem(OPEN_REPOS_KEY, JSON.stringify(repoTabs.map((tab) => tab.path))); localStorage.setItem(RECENT_REPOS_KEY, JSON.stringify(recentRepoPaths)); @@ -2073,6 +2136,11 @@ } function upsertRepoTab(path: string, nextStatus?: GitStatus | null) { + const workspace = workspaceState.workspaces.find(item => item.id === workspaceState.selectedId); + if (workspace && !workspace.repositories.some(repo => sameRepoPath(repo, path))) { + workspace.repositories = [...workspace.repositories, path]; + workspaceState = { ...workspaceState }; + } const existing = repoTabs.find((tab) => sameRepoPath(tab.path, path)); const next: RepoTab = { path, @@ -5405,6 +5473,9 @@ import { Columns3, Database, Folder, House, GitPullRequest, Plus, X } from "@lucide/svelte"; + import SelectMenu from "./components/SelectMenu.svelte"; + interface RepositoryTabItem { path: string; name: string; @@ -22,6 +24,10 @@ export let onAdd: () => void | Promise = () => {}; export let onReorder: (path: string, targetPath: string, after: boolean) => void = () => {}; + export let workspaceId = ""; + export let workspaceOptions: { value: string; label: string }[] = []; + export let onWorkspaceChange: (id: string) => unknown = () => {}; + let navigation: HTMLElement; let drag: { path: string; pointerId: number; startX: number; startScroll: number; source: number; target: number; width: number; centers: number[]; element: HTMLElement } | null = null; let dragging = false; @@ -89,6 +95,7 @@ {#if activeView === "repository"} +
+
+
{/if}