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}