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
This commit is contained in:
2026-09-11 14:15:27 +02:00
parent 54972f24b8
commit be073a8f39
5 changed files with 216 additions and 58 deletions
+74 -1
View File
@@ -14,6 +14,7 @@
import type { PullRequestBadgeState } from "./lib/pullRequestBadges";
let pullRequestBadges: Record<string, PullRequestBadgeState> = {};
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<string, RepoTab> = {};
$: 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 @@
<RepoTabs
{activeView}
{workspaceOptions}
workspaceId={workspaceState.selectedId}
onWorkspaceChange={switchWorkspace}
{repoTabs}
{isBusy}
language={appLanguage}
@@ -5581,6 +5652,8 @@
<RepositoryDashboard
repos={dashboardRepos} language={appLanguage} {isBusy}
{pullRequestBadges}
workspaces={workspaceState.workspaces} selectedWorkspace={workspaceState.selectedId}
onWorkspaceChange={switchWorkspace} onSaveWorkspace={saveWorkspace} onDeleteWorkspace={deleteWorkspace}
onOpen={selectRepoTab} onAdd={chooseRepositoryFolder} onClone={openCloneDialog}
onInit={initializeRepository}
onFavorite={toggleFavoriteRepo} onClose={closeDashboardRepository}
+14 -1
View File
@@ -1,6 +1,8 @@
<script lang="ts">
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<void> = () => {};
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 @@
<button type="button" class:active={activeView === "issues"} aria-current={activeView === "issues" ? "page" : undefined} disabled={isBusy} onclick={onOpenIssues}><Columns3 size={17}/><span>Issues &amp; Boards</span></button>
</nav>
{#if activeView === "repository"}
<div class="repository-row">
<nav bind:this={navigation} class="repository-navigation" class:reordering={dragging} aria-label={language === "de" ? "Geöffnete Repositories" : "Open repositories"}>
{#each repoTabs as repo, index (repo.path)}
<div class="repository-tab" class:active={isActive(repo.path)}
@@ -103,10 +110,16 @@
{/each}
<button class="repository-add" type="button" onclick={onAdd} disabled={isBusy} title={language === "de" ? "Repository-Ordner öffnen" : "Open repository folder"} aria-label={language === "de" ? "Repository-Ordner öffnen" : "Open repository folder"}><Plus size={18}/></button>
</nav>
<div class="workspace-picker"><SelectMenu options={workspaceOptions} value={workspaceId} onChange={onWorkspaceChange} disabled={isBusy} ariaLabel={language === "de" ? "Workspace wechseln" : "Switch workspace"} /></div>
</div>
{/if}
</header>
<style>
.repository-row{display:flex;min-width:0;align-items:stretch;background:color-mix(in srgb,var(--color-surface) 45%,var(--app-bg))}
.workspace-picker{flex:0 0 180px;min-width:0;align-self:center;padding:3px 5px 3px 8px;border-left:1px solid var(--color-border-subtle)}
.workspace-picker :global(.select-menu-trigger){min-height:28px;height:28px;font-size:12px}
@media(max-width:600px){.workspace-picker{flex-basis:140px}}
.workspace-navigation{flex:0 0 auto;min-width:0;background:var(--app-bg);color:var(--color-ink-muted);font-family:var(--font-sans);border-bottom:1px solid var(--color-border-subtle)}
.global-navigation{display:flex;align-items:stretch;gap:2px;min-height:38px;padding:0 5px;overflow-x:auto;scrollbar-width:thin;border-bottom:1px solid var(--color-border-subtle)}
button{font:inherit;cursor:pointer;color:inherit;background:transparent;border:0;box-shadow:none}
@@ -117,7 +130,7 @@
.global-navigation button.active{color:var(--color-ink);font-weight:600}
.global-navigation button.active::after{position:absolute;content:"";height:2px;bottom:0;left:12px;right:12px;background:var(--color-accent)}
.global-navigation button.active>:global(svg){color:var(--color-accent)}
.repository-navigation{display:flex;align-items:stretch;gap:4px;min-height:34px;padding:4px 5px 0;overflow-x:auto;scrollbar-width:thin;background:color-mix(in srgb,var(--color-surface) 45%,var(--app-bg))}
.repository-navigation{flex:1;min-width:0;display:flex;align-items:stretch;gap:4px;min-height:34px;padding:4px 5px 0;overflow-x:auto;scrollbar-width:thin;background:color-mix(in srgb,var(--color-surface) 45%,var(--app-bg))}
.repository-tab{display:flex;flex:0 0 auto;align-items:center;min-width:100px;max-width:250px;border:1px solid var(--color-border);border-bottom:0;border-radius:5px 5px 0 0;background:var(--app-bg)}
.repository-tab.active{background:var(--color-surface-raised);border-color:var(--color-border-input);color:var(--color-ink)}
.repository-tab:hover{background:var(--color-surface-hover)}
+41 -56
View File
@@ -17,7 +17,7 @@
recent: boolean;
}
interface Workspace { id: string; name: string; }
import type { Workspace } from "../workspaces";
export let repos: DashboardRepository[] = [];
export let language: "de" | "en" = "en";
export let isBusy = false;
@@ -33,10 +33,13 @@
const STORAGE_KEY = "gitty.dashboard.v1";
let query = "";
let viewMode: "cards" | "list" = "list";
let selectedWorkspace = "";
export let selectedWorkspace = "";
export let onWorkspaceChange: (id: string) => unknown;
export let onSaveWorkspace: (id: string, name: string, repositories: string[]) => unknown;
export let onDeleteWorkspace: () => unknown;
let editingWorkspaceId = "";
let collapsedCategories = new Set<string>();
let workspaces: Workspace[] = [];
let assignments: Record<string, string> = {};
export let workspaces: Workspace[] = [];
let workspaceDialog: HTMLDialogElement;
let workspaceInput: HTMLInputElement;
let workspaceName = "";
@@ -46,21 +49,22 @@
$: de = language === "de";
$: normalizedQuery = query.trim().toLocaleLowerCase();
$: filteredRepos = repos.filter((repo) => {
$: workspaceRepos = selectedWorkspace ? repos.filter(repo => workspaces.find(item => item.id === selectedWorkspace)?.repositories.includes(repo.path)) : repos;
$: filteredRepos = workspaceRepos.filter((repo) => {
const matchesSearch = !normalizedQuery || `${repo.name} ${repo.path} ${repo.branch ?? ""}`.toLocaleLowerCase().includes(normalizedQuery);
return matchesSearch && (!selectedWorkspace || assignments[repo.path] === selectedWorkspace);
return matchesSearch;
});
$: openRepos = filteredRepos.filter((repo) => repo.isOpen);
$: favoriteRepos = filteredRepos.filter((repo) => repo.favorite);
$: recentRepos = filteredRepos.filter((repo) => repo.recent && !repo.isOpen);
$: recentRepos = filteredRepos.filter((repo) => (repo.recent || !!selectedWorkspace) && !repo.isOpen);
$: categories = [
{ id: "open", label: "Open", repos: openRepos },
{ id: "favorites", label: de ? "Favoriten" : "Favorites", repos: favoriteRepos },
{ id: "recent", label: "Recent", repos: recentRepos },
{ id: "recent", label: selectedWorkspace ? (de ? "Weitere Repositories" : "Other repositories") : "Recent", repos: recentRepos },
];
$: changedCount = repos.filter((repo) => repo.known && repo.changed > 0).length;
$: changedCount = workspaceRepos.filter((repo) => repo.known && repo.changed > 0).length;
$: workspaceOptions = [
{ value: "", label: de ? "Alle Workspaces" : "All workspaces" },
{ value: "", label: de ? "Alle Repositories" : "All repositories" },
...workspaces.map((workspace) => ({ value: workspace.id, label: workspace.name, group: "Workspaces" })),
];
loadPreferences();
@@ -69,26 +73,12 @@
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}") as { workspaces?: unknown; assignments?: unknown; viewMode?: unknown };
viewMode = saved.viewMode === "cards" ? "cards" : "list";
if (Array.isArray(saved.workspaces)) {
workspaces = saved.workspaces.filter((item): item is Workspace => Boolean(
item && typeof item === "object"
&& "id" in item && typeof item.id === "string" && item.id.startsWith("workspace-")
&& "name" in item && typeof item.name === "string" && item.name.trim(),
));
}
if (saved.assignments && typeof saved.assignments === "object" && !Array.isArray(saved.assignments)) {
assignments = Object.fromEntries(Object.entries(saved.assignments).filter(([, workspaceId]) =>
typeof workspaceId === "string" && workspaces.some((workspace) => workspace.id === workspaceId)));
}
} catch {
workspaces = [];
assignments = {};
}
} catch { /* Optional display preferences. */ }
}
function savePreferences() {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ workspaces, assignments, viewMode })); }
catch { /* Preferences remain optional when storage is unavailable. */ }
try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ viewMode })); }
catch { /* Optional display preferences. */ }
}
function setViewMode(mode: "cards" | "list") {
@@ -96,10 +86,12 @@
savePreferences();
}
function openWorkspaceDialog() {
workspaceName = "";
function openWorkspaceDialog(edit = false) {
const workspace = edit ? workspaces.find(item => item.id === selectedWorkspace) : undefined;
editingWorkspaceId = workspace?.id ?? "";
workspaceName = workspace?.name ?? "";
workspaceError = "";
workspaceSelection = new Set();
workspaceSelection = new Set(workspace?.repositories ?? []);
workspaceDialog.showModal();
requestAnimationFrame(() => workspaceInput.focus());
}
@@ -118,27 +110,14 @@
workspaceError = de ? "Bitte einen Namen eingeben." : "Please enter a name.";
return;
}
if (workspaces.some((workspace) => workspace.name.toLocaleLowerCase() === name.toLocaleLowerCase())) {
if (workspaces.some((workspace) => workspace.id !== editingWorkspaceId && workspace.name.toLocaleLowerCase() === name.toLocaleLowerCase())) {
workspaceError = de ? "Dieser Workspace existiert bereits." : "This workspace already exists.";
return;
}
const id = `workspace-${crypto.randomUUID()}`;
workspaces = [...workspaces, { id, name }];
assignments = { ...assignments, ...Object.fromEntries([...workspaceSelection].map((path) => [path, id])) };
selectedWorkspace = id;
savePreferences();
void onSaveWorkspace(editingWorkspaceId, name, [...workspaceSelection]);
workspaceDialog.close();
}
function deleteSelectedWorkspace() {
if (!selectedWorkspace) return;
const deletedWorkspace = selectedWorkspace;
workspaces = workspaces.filter((workspace) => workspace.id !== deletedWorkspace);
assignments = Object.fromEntries(Object.entries(assignments).filter(([, workspaceId]) => workspaceId !== deletedWorkspace));
selectedWorkspace = "";
savePreferences();
}
function openCard(path: string) { if (!isBusy) onOpen(path); }
function closeCard(event: MouseEvent, path: string) { event.stopPropagation(); if (!isBusy) onClose(path); }
function favoriteCard(event: MouseEvent, path: string) { event.stopPropagation(); if (!isBusy) onFavorite(path); }
@@ -188,16 +167,18 @@
value={selectedWorkspace}
options={workspaceOptions}
ariaLabel={de ? "Workspace auswählen" : "Select workspace"}
onChange={(value) => { selectedWorkspace = value; }}
disabled={isBusy}
onChange={onWorkspaceChange}
/>
{#if selectedWorkspace}
<button class="workspace-delete" type="button" onclick={deleteSelectedWorkspace} aria-label={de ? "Ausgewählten Workspace löschen" : "Delete selected workspace"} title={de ? "Workspace löschen" : "Delete workspace"}><Trash2 size={15} strokeWidth={2} /></button>
<button type="button" disabled={isBusy} onclick={() => openWorkspaceDialog(true)}>{de ? "Bearbeiten" : "Edit"}</button>
<button class="workspace-delete" type="button" disabled={isBusy} onclick={onDeleteWorkspace} aria-label={de ? "Ausgewählten Workspace löschen" : "Delete selected workspace"} title={de ? "Workspace löschen" : "Delete workspace"}><Trash2 size={15} strokeWidth={2} /></button>
{/if}
<button type="button" onclick={openWorkspaceDialog}><Plus size={14} />Workspace</button>
<button type="button" disabled={isBusy} onclick={() => openWorkspaceDialog()}><Plus size={14} />Workspace</button>
</div>
</div>
<div class="dashboard-summary">{repos.length} Repositories <span>·</span> {changedCount} {de ? "mit Änderungen" : "with changes"}</div>
<div class="dashboard-summary">{workspaceRepos.length} Repositories <span>·</span> {changedCount} {de ? "mit Änderungen" : "with changes"}</div>
<div class="dashboard-content" class:table-view={viewMode === "list"}>
{#each categories as category (category.id)}
@@ -230,7 +211,7 @@
</button>
<button class:active={repo.favorite} type="button" onclick={(event) => favoriteCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Favorit umschalten" : "Toggle favorite"}`} aria-pressed={repo.favorite}><Star size={15} /></button>
{#if repo.isOpen && category.id === "open"}<button type="button" onclick={(event) => closeCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Schließen" : "Close"}`} title={de ? "Repository schließen" : "Close repository"}><X size={15} /></button>{/if}
{#if category.id === "recent"}<button type="button" onclick={(event) => removeRecentCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Aus Recent entfernen" : "Remove from recent"}`} title={de ? "Aus Recent entfernen" : "Remove from recent"}><X size={15} /></button>{/if}
{#if category.id === "recent" && !selectedWorkspace}<button type="button" onclick={(event) => removeRecentCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Aus Recent entfernen" : "Remove from recent"}`} title={de ? "Aus Recent entfernen" : "Remove from recent"}><X size={15} /></button>{/if}
</div>
</article>
{/each}
@@ -242,21 +223,21 @@
</section>
{/each}
</div>
<footer class="dashboard-footer">{repos.length} {de ? "lokale Repositories" : "local repositories"}</footer>
<footer class="dashboard-footer">{workspaceRepos.length} {de ? "lokale Repositories" : "local repositories"}</footer>
</section>
<dialog class="workspace-dialog" bind:this={workspaceDialog} aria-labelledby="workspace-title">
<form onsubmit={createWorkspace}>
<header><h2 id="workspace-title">{de ? "Workspace anlegen" : "Create workspace"}</h2><button data-dialog-close type="button" onclick={() => workspaceDialog.close()} aria-label={de ? "Dialog schließen" : "Close dialog"}><X size={14} /></button></header>
<header><h2 id="workspace-title">{editingWorkspaceId ? (de ? "Workspace bearbeiten" : "Edit workspace") : (de ? "Workspace anlegen" : "Create workspace")}</h2><button data-dialog-close type="button" onclick={() => workspaceDialog.close()} aria-label={de ? "Dialog schließen" : "Close dialog"}><X size={14} /></button></header>
<label for="workspace-name">Name</label>
<input id="workspace-name" bind:this={workspaceInput} bind:value={workspaceName} maxlength="64" autocomplete="off" aria-invalid={Boolean(workspaceError)} />
{#if workspaceError}<p class="dialog-error" role="alert">{workspaceError}</p>{/if}
{#if repos.length > 0}
<fieldset><legend>{de ? "Repositories zuordnen" : "Assign repositories"}</legend><div class="workspace-repos">
{#each repos as repo (repo.path)}<label><input type="checkbox" checked={workspaceSelection.has(repo.path)} onchange={() => toggleWorkspaceRepo(repo.path)} /><span>{repo.name}</span><small>{repo.path}</small></label>{/each}
<fieldset><legend>{de ? "Repositories zuordnen" : "Assign repositories"} · {workspaceSelection.size} {de ? "ausgewählt" : "selected"}</legend><div class="workspace-repos">
{#each repos as repo (repo.path)}<label class:selected={workspaceSelection.has(repo.path)}><input type="checkbox" checked={workspaceSelection.has(repo.path)} onchange={() => toggleWorkspaceRepo(repo.path)} /><span>{repo.name}</span><small>{repo.path}</small></label>{/each}
</div></fieldset>
{/if}
<footer><button type="button" onclick={() => workspaceDialog.close()}>{de ? "Abbrechen" : "Cancel"}</button><button class="primary" type="submit"><Check size={14} />{de ? "Anlegen" : "Create"}</button></footer>
<footer><button type="button" onclick={() => workspaceDialog.close()}>{de ? "Abbrechen" : "Cancel"}</button><button class="primary" type="submit"><Check size={14} />{editingWorkspaceId ? (de ? "Speichern" : "Save") : (de ? "Anlegen" : "Create")}</button></footer>
</form>
</dialog>
@@ -268,7 +249,11 @@
.repo-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.repo-card{position:relative;min-width:0;height:138px;border:1px solid var(--color-border);background:var(--color-surface)}.repo-card:hover,.repo-card:focus-within{border-color:var(--color-border-input);background:var(--color-surface-hover)}.card-main{display:flex;width:100%;height:100%;flex-direction:column;align-items:flex-start;gap:7px;padding:12px 104px 11px 14px;border:0;background:transparent;text-align:left}.card-main:focus-visible{outline:2px solid var(--color-accent);outline-offset:-2px}.card-title{display:flex;max-width:100%;align-items:center;gap:8px}.card-title strong{overflow:hidden;font-size:14px;font-weight:700;text-overflow:ellipsis;white-space:nowrap}.card-branch,.card-status,.card-status>span{display:flex;align-items:center;gap:6px}.card-branch{max-width:100%;overflow:hidden;color:var(--color-ink-muted);text-overflow:ellipsis;white-space:nowrap}.card-status{flex-wrap:wrap;gap:8px 18px;margin-top:auto}.card-status :global(.filled){fill:currentColor}.changed{color:#eeb94e}.behind{color:var(--color-sync-behind)}.clean{color:#68c878}.ahead{color:var(--color-sync-ahead)}.muted{color:var(--color-ink-muted)}
.card-actions{position:absolute;top:8px;right:8px;z-index:2;display:flex;align-items:center;gap:2px}.card-actions button{display:grid;width:26px;height:26px;place-items:center;padding:0;border:0;color:var(--color-ink-muted);background:transparent}.card-actions button:hover:not(:disabled){color:var(--color-ink);background:var(--color-surface-dim)}.card-actions button.active{color:var(--color-accent)}.card-actions button.active :global(svg){fill:color-mix(in srgb,var(--color-accent) 22%,transparent)}.card-actions .pr-badge{display:flex;width:auto;min-width:31px;grid-template-columns:none;align-items:center;justify-content:center;gap:4px;padding:0 5px;color:var(--color-ink-faint);font-size:11px}.card-actions .pr-badge:disabled{opacity:.72}.card-actions .pr-badge.has-open-prs{color:var(--color-accent)}.card-actions .pr-badge.pr-error{color:#e0a35b}.card-actions .pr-badge:hover:not(:disabled){color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 9%,transparent)}
.dashboard-footer{display:flex;min-height:30px;align-items:center;padding:0 20px;border-top:1px solid var(--color-border-subtle);color:var(--color-ink-muted)}
.workspace-dialog{width:min(420px,calc(100vw - 32px));margin:auto;padding:0;border:1px solid var(--color-border);color:var(--color-ink);background:var(--app-dialog-bg);box-shadow:var(--app-dialog-shadow);font-size:12px}.workspace-dialog::backdrop{background:var(--app-dialog-backdrop)}.workspace-dialog form>header,.workspace-dialog form>footer{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:var(--app-dialog-chrome)}.workspace-dialog form>header{border-bottom:1px solid var(--color-border)}.workspace-dialog form>footer{justify-content:flex-end;gap:8px;border-top:1px solid var(--color-border)}.workspace-dialog h2{margin:0;font-size:13px}.workspace-dialog header button{width:26px;height:26px;padding:0;border:0;background:transparent}.workspace-dialog form>label,.workspace-dialog form>input,.workspace-dialog fieldset,.dialog-error{margin-right:12px;margin-left:12px}.workspace-dialog form>label{display:block;margin-top:12px;margin-bottom:5px;color:var(--color-ink-muted)}.workspace-dialog form>input{width:calc(100% - 24px);height:30px;padding:0 8px;border:1px solid var(--color-border-input);color:var(--color-ink);background:var(--app-input-bg)}.dialog-error{margin-top:7px;color:#ed9292}.workspace-dialog fieldset{margin-top:14px;margin-bottom:14px;padding:0;border:0}.workspace-dialog legend{margin-bottom:6px;color:var(--color-ink-muted)}.workspace-repos{max-height:190px;overflow:auto;border:1px solid var(--color-border-subtle)}.workspace-repos label{display:grid;grid-template-columns:auto minmax(100px,auto) 1fr;align-items:center;gap:8px;min-height:32px;padding:4px 8px;border-bottom:1px solid var(--color-border-subtle)}.workspace-repos label:last-child{border-bottom:0}.workspace-repos small{overflow:hidden;color:var(--color-ink-faint);text-overflow:ellipsis;white-space:nowrap}.workspace-dialog footer button{display:inline-flex;min-height:28px;align-items:center;gap:6px;padding:0 10px;border:1px solid var(--color-border);background:var(--app-button-bg)}
.workspace-repos input[type="checkbox"]{appearance:auto;-webkit-appearance:checkbox;width:16px;min-width:16px;height:16px;margin:0;padding:0;accent-color:var(--color-primary);cursor:pointer}
.workspace-repos input[type="checkbox"]:focus-visible{outline:2px solid var(--color-accent);outline-offset:3px}
.workspace-repos label{cursor:pointer}
.workspace-repos label.selected{background:color-mix(in srgb,var(--color-primary) 14%,var(--app-dialog-bg));box-shadow:inset 3px 0 var(--color-primary)}
.workspace-dialog{width:min(420px,calc(100vw - 32px));margin:auto;padding:0;border:1px solid var(--color-border);color:var(--color-ink);background:var(--app-dialog-bg);box-shadow:var(--app-dialog-shadow);font-size:12px}.workspace-dialog::backdrop{background:var(--app-dialog-backdrop)}.workspace-dialog form>header,.workspace-dialog form>footer{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:var(--app-dialog-chrome)}.workspace-dialog form>header{border-bottom:1px solid var(--color-border)}.workspace-dialog form>footer{justify-content:flex-end;gap:8px;border-top:1px solid var(--color-border)}.workspace-dialog h2{margin:0;font-size:13px}.workspace-dialog header button{width:26px;height:26px;padding:0;border:0;background:transparent}.workspace-dialog form>label,.workspace-dialog form>input,.workspace-dialog fieldset,.dialog-error{margin-right:12px;margin-left:12px}.workspace-dialog form>label{display:block;margin-top:12px;margin-bottom:5px;color:var(--color-ink-muted)}.workspace-dialog form>input{width:calc(100% - 24px);height:30px;padding:0 8px;border:1px solid var(--color-border-input);color:var(--color-ink);background:var(--app-input-bg)}.dialog-error{margin-top:7px;color:#ed9292}.workspace-dialog fieldset{margin-top:14px;margin-bottom:14px;padding:0;border:0}.workspace-dialog legend{margin-bottom:6px;color:var(--color-ink-muted)}.workspace-repos{max-height:190px;overflow:auto;border:1px solid var(--color-border-subtle)}.workspace-repos label{display:grid;grid-template-columns:16px minmax(100px,auto) 1fr;align-items:center;gap:8px;min-height:32px;padding:4px 8px;border-bottom:1px solid var(--color-border-subtle)}.workspace-repos label:last-child{border-bottom:0}.workspace-repos small{overflow:hidden;color:var(--color-ink-faint);text-overflow:ellipsis;white-space:nowrap}.workspace-dialog footer button{display:inline-flex;min-height:28px;align-items:center;gap:6px;padding:0 10px;border:1px solid var(--color-border);background:var(--app-button-bg)}
@media(max-width:1000px){.dashboard-toolbar{flex-wrap:wrap;gap:8px}.dashboard-search{flex:1 1 260px}.workspace-tools{margin-left:0}.repo-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:680px){.dashboard-header{min-height:auto;align-items:flex-start;flex-direction:column}.header-actions{flex-wrap:wrap}.workspace-tools{width:100%}.workspace-tools :global(.workspace-select){min-width:0;flex:1}.repo-grid{grid-template-columns:1fr}.repo-card{height:132px}}
.view-switch{display:flex;flex:0 0 auto;height:30px;border:1px solid var(--color-border-input);background:var(--app-input-bg)}
+42
View File
@@ -0,0 +1,42 @@
export interface Workspace {
id: string;
name: string;
repositories: string[];
openPaths: string[];
activePath: string;
}
export interface WorkspaceState {
selectedId: string;
workspaces: Workspace[];
defaultSession: { openPaths: string[]; activePath: string };
}
export const WORKSPACES_KEY = "gitlite.workspaces.v1";
const paths = (value: unknown): string[] => Array.isArray(value)
? [...new Set(value.filter((path): path is string => typeof path === "string" && !!path.trim()))] : [];
export function readWorkspaces(storage: Pick<Storage, "getItem">, openPaths: string[]): WorkspaceState {
const fallback: WorkspaceState = { selectedId: "", workspaces: [], defaultSession: { openPaths, activePath: "" } };
try {
const raw = storage.getItem(WORKSPACES_KEY);
if (raw) {
const saved = JSON.parse(raw);
const workspaces: Workspace[] = Array.isArray(saved.workspaces) ? saved.workspaces.flatMap((item: any) => {
if (!item || typeof item.id !== "string" || !item.id.startsWith("workspace-") || typeof item.name !== "string" || !item.name.trim()) return [];
const repositories = paths(item.repositories);
const openPaths = paths(item.openPaths).filter(path => repositories.includes(path));
return [{ id: item.id, name: item.name, repositories, openPaths,
activePath: openPaths.includes(item.activePath) ? item.activePath : openPaths[0] ?? "" }];
}) : [];
const defaultPaths = paths(saved.defaultSession?.openPaths);
return { workspaces, selectedId: workspaces.some(item => item.id === saved.selectedId) ? saved.selectedId : "",
defaultSession: { openPaths: defaultPaths, activePath: defaultPaths.includes(saved.defaultSession?.activePath) ? saved.defaultSession.activePath : defaultPaths[0] ?? "" } };
}
const legacy = JSON.parse(storage.getItem("gitty.dashboard.v1") ?? "{}");
if (Array.isArray(legacy.workspaces)) fallback.workspaces = legacy.workspaces.flatMap((item: any) => {
if (!item || typeof item.id !== "string" || !item.id.startsWith("workspace-") || typeof item.name !== "string" || !item.name.trim()) return [];
const repositories = Object.entries(legacy.assignments ?? {}).filter(([, id]) => id === item.id).map(([path]) => path);
const tabs = openPaths.filter(path => repositories.includes(path));
return [{ ...item, repositories, openPaths: tabs, activePath: tabs[0] ?? "" }];
});
} catch { /* Invalid optional preferences must not block startup. */ }
return fallback;
}