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:
@@ -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']);
|
||||||
|
}
|
||||||
|
});
|
||||||
+74
-1
@@ -14,6 +14,7 @@
|
|||||||
import type { PullRequestBadgeState } from "./lib/pullRequestBadges";
|
import type { PullRequestBadgeState } from "./lib/pullRequestBadges";
|
||||||
let pullRequestBadges: Record<string, PullRequestBadgeState> = {};
|
let pullRequestBadges: Record<string, PullRequestBadgeState> = {};
|
||||||
import IssueCenter from "./lib/components/IssueCenter.svelte";
|
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 RepositoryDashboard from "./lib/components/RepositoryDashboard.svelte";
|
||||||
import ReviewCenter from "./lib/components/ReviewCenter.svelte";
|
import ReviewCenter from "./lib/components/ReviewCenter.svelte";
|
||||||
import RepoTabs from "./lib/RepoTabs.svelte";
|
import RepoTabs from "./lib/RepoTabs.svelte";
|
||||||
@@ -331,13 +332,15 @@
|
|||||||
let reviewConflictRemote = "";
|
let reviewConflictRemote = "";
|
||||||
let reviewConflictPushRequested = false;
|
let reviewConflictPushRequested = false;
|
||||||
let repoTabs: RepoTab[] = [];
|
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 repoTabContextMenu: RepoTabContextMenu | null = null;
|
||||||
let recentRepoPaths: string[] = [];
|
let recentRepoPaths: string[] = [];
|
||||||
let favoriteRepoPaths: string[] = [];
|
let favoriteRepoPaths: string[] = [];
|
||||||
// Last-seen branch/ahead/behind/changed for repos that are known (recent/favorites)
|
// 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).
|
// but not currently open as a tab — keyed by normalized path (repoKey).
|
||||||
let repoStatusCache: Record<string, RepoTab> = {};
|
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),
|
.map(path => ({ ...repoRowFromPath(path, repoTabs, repoStatusCache),
|
||||||
isOpen: repoTabs.some(tab => sameRepoPath(tab.path, path)),
|
isOpen: repoTabs.some(tab => sameRepoPath(tab.path, path)),
|
||||||
known: Boolean(repoStatusCache[repoKey(path)]),
|
known: Boolean(repoStatusCache[repoKey(path)]),
|
||||||
@@ -701,6 +704,9 @@
|
|||||||
|
|
||||||
initAnalytics();
|
initAnalytics();
|
||||||
loadRepoLists();
|
loadRepoLists();
|
||||||
|
workspaceState = readWorkspaces(localStorage, repoTabs.map(tab => tab.path));
|
||||||
|
repoTabs = currentWorkspaceSession().openPaths.map(path => repoRowFromPath(path));
|
||||||
|
persistWorkspaces();
|
||||||
|
|
||||||
void checkForUpdates();
|
void checkForUpdates();
|
||||||
aiSettings = loadAiSettings();
|
aiSettings = loadAiSettings();
|
||||||
@@ -714,6 +720,7 @@
|
|||||||
await closeStartupSplashscreen();
|
await closeStartupSplashscreen();
|
||||||
startupReady = true;
|
startupReady = true;
|
||||||
await receiveStartupRepository();
|
await receiveStartupRepository();
|
||||||
|
if (!activeRepoPath && currentWorkspaceSession().activePath) await openRepo(currentWorkspaceSession().activePath);
|
||||||
startBackgroundTimers();
|
startBackgroundTimers();
|
||||||
// Remote access can take seconds (offline networks, SSH negotiation,
|
// Remote access can take seconds (offline networks, SSH negotiation,
|
||||||
// credential helpers). It must never hold the startup screen hostage.
|
// 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() {
|
function persistRepoLists() {
|
||||||
|
persistWorkspaces();
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(OPEN_REPOS_KEY, JSON.stringify(repoTabs.map((tab) => tab.path)));
|
localStorage.setItem(OPEN_REPOS_KEY, JSON.stringify(repoTabs.map((tab) => tab.path)));
|
||||||
localStorage.setItem(RECENT_REPOS_KEY, JSON.stringify(recentRepoPaths));
|
localStorage.setItem(RECENT_REPOS_KEY, JSON.stringify(recentRepoPaths));
|
||||||
@@ -2073,6 +2136,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function upsertRepoTab(path: string, nextStatus?: GitStatus | null) {
|
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 existing = repoTabs.find((tab) => sameRepoPath(tab.path, path));
|
||||||
const next: RepoTab = {
|
const next: RepoTab = {
|
||||||
path,
|
path,
|
||||||
@@ -5405,6 +5473,9 @@
|
|||||||
|
|
||||||
<RepoTabs
|
<RepoTabs
|
||||||
{activeView}
|
{activeView}
|
||||||
|
{workspaceOptions}
|
||||||
|
workspaceId={workspaceState.selectedId}
|
||||||
|
onWorkspaceChange={switchWorkspace}
|
||||||
{repoTabs}
|
{repoTabs}
|
||||||
{isBusy}
|
{isBusy}
|
||||||
language={appLanguage}
|
language={appLanguage}
|
||||||
@@ -5581,6 +5652,8 @@
|
|||||||
<RepositoryDashboard
|
<RepositoryDashboard
|
||||||
repos={dashboardRepos} language={appLanguage} {isBusy}
|
repos={dashboardRepos} language={appLanguage} {isBusy}
|
||||||
{pullRequestBadges}
|
{pullRequestBadges}
|
||||||
|
workspaces={workspaceState.workspaces} selectedWorkspace={workspaceState.selectedId}
|
||||||
|
onWorkspaceChange={switchWorkspace} onSaveWorkspace={saveWorkspace} onDeleteWorkspace={deleteWorkspace}
|
||||||
onOpen={selectRepoTab} onAdd={chooseRepositoryFolder} onClone={openCloneDialog}
|
onOpen={selectRepoTab} onAdd={chooseRepositoryFolder} onClone={openCloneDialog}
|
||||||
onInit={initializeRepository}
|
onInit={initializeRepository}
|
||||||
onFavorite={toggleFavoriteRepo} onClose={closeDashboardRepository}
|
onFavorite={toggleFavoriteRepo} onClose={closeDashboardRepository}
|
||||||
|
|||||||
+14
-1
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Columns3, Database, Folder, House, GitPullRequest, Plus, X } from "@lucide/svelte";
|
import { Columns3, Database, Folder, House, GitPullRequest, Plus, X } from "@lucide/svelte";
|
||||||
|
|
||||||
|
import SelectMenu from "./components/SelectMenu.svelte";
|
||||||
|
|
||||||
interface RepositoryTabItem {
|
interface RepositoryTabItem {
|
||||||
path: string;
|
path: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -22,6 +24,10 @@
|
|||||||
export let onAdd: () => void | Promise<void> = () => {};
|
export let onAdd: () => void | Promise<void> = () => {};
|
||||||
export let onReorder: (path: string, targetPath: string, after: boolean) => 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 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 drag: { path: string; pointerId: number; startX: number; startScroll: number; source: number; target: number; width: number; centers: number[]; element: HTMLElement } | null = null;
|
||||||
let dragging = false;
|
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 & Boards</span></button>
|
<button type="button" class:active={activeView === "issues"} aria-current={activeView === "issues" ? "page" : undefined} disabled={isBusy} onclick={onOpenIssues}><Columns3 size={17}/><span>Issues & Boards</span></button>
|
||||||
</nav>
|
</nav>
|
||||||
{#if activeView === "repository"}
|
{#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"}>
|
<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)}
|
{#each repoTabs as repo, index (repo.path)}
|
||||||
<div class="repository-tab" class:active={isActive(repo.path)}
|
<div class="repository-tab" class:active={isActive(repo.path)}
|
||||||
@@ -103,10 +110,16 @@
|
|||||||
{/each}
|
{/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>
|
<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>
|
</nav>
|
||||||
|
<div class="workspace-picker"><SelectMenu options={workspaceOptions} value={workspaceId} onChange={onWorkspaceChange} disabled={isBusy} ariaLabel={language === "de" ? "Workspace wechseln" : "Switch workspace"} /></div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<style>
|
<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)}
|
.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)}
|
.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}
|
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{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::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)}
|
.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{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.active{background:var(--color-surface-raised);border-color:var(--color-border-input);color:var(--color-ink)}
|
||||||
.repository-tab:hover{background:var(--color-surface-hover)}
|
.repository-tab:hover{background:var(--color-surface-hover)}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
recent: boolean;
|
recent: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Workspace { id: string; name: string; }
|
import type { Workspace } from "../workspaces";
|
||||||
export let repos: DashboardRepository[] = [];
|
export let repos: DashboardRepository[] = [];
|
||||||
export let language: "de" | "en" = "en";
|
export let language: "de" | "en" = "en";
|
||||||
export let isBusy = false;
|
export let isBusy = false;
|
||||||
@@ -33,10 +33,13 @@
|
|||||||
const STORAGE_KEY = "gitty.dashboard.v1";
|
const STORAGE_KEY = "gitty.dashboard.v1";
|
||||||
let query = "";
|
let query = "";
|
||||||
let viewMode: "cards" | "list" = "list";
|
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 collapsedCategories = new Set<string>();
|
||||||
let workspaces: Workspace[] = [];
|
export let workspaces: Workspace[] = [];
|
||||||
let assignments: Record<string, string> = {};
|
|
||||||
let workspaceDialog: HTMLDialogElement;
|
let workspaceDialog: HTMLDialogElement;
|
||||||
let workspaceInput: HTMLInputElement;
|
let workspaceInput: HTMLInputElement;
|
||||||
let workspaceName = "";
|
let workspaceName = "";
|
||||||
@@ -46,21 +49,22 @@
|
|||||||
|
|
||||||
$: de = language === "de";
|
$: de = language === "de";
|
||||||
$: normalizedQuery = query.trim().toLocaleLowerCase();
|
$: 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);
|
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);
|
$: openRepos = filteredRepos.filter((repo) => repo.isOpen);
|
||||||
$: favoriteRepos = filteredRepos.filter((repo) => repo.favorite);
|
$: favoriteRepos = filteredRepos.filter((repo) => repo.favorite);
|
||||||
$: recentRepos = filteredRepos.filter((repo) => repo.recent && !repo.isOpen);
|
$: recentRepos = filteredRepos.filter((repo) => (repo.recent || !!selectedWorkspace) && !repo.isOpen);
|
||||||
$: categories = [
|
$: categories = [
|
||||||
{ id: "open", label: "Open", repos: openRepos },
|
{ id: "open", label: "Open", repos: openRepos },
|
||||||
{ id: "favorites", label: de ? "Favoriten" : "Favorites", repos: favoriteRepos },
|
{ 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 = [
|
$: 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" })),
|
...workspaces.map((workspace) => ({ value: workspace.id, label: workspace.name, group: "Workspaces" })),
|
||||||
];
|
];
|
||||||
loadPreferences();
|
loadPreferences();
|
||||||
@@ -69,26 +73,12 @@
|
|||||||
try {
|
try {
|
||||||
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}") as { workspaces?: unknown; assignments?: unknown; viewMode?: unknown };
|
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}") as { workspaces?: unknown; assignments?: unknown; viewMode?: unknown };
|
||||||
viewMode = saved.viewMode === "cards" ? "cards" : "list";
|
viewMode = saved.viewMode === "cards" ? "cards" : "list";
|
||||||
if (Array.isArray(saved.workspaces)) {
|
} catch { /* Optional display preferences. */ }
|
||||||
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 = {};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function savePreferences() {
|
function savePreferences() {
|
||||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ workspaces, assignments, viewMode })); }
|
try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ viewMode })); }
|
||||||
catch { /* Preferences remain optional when storage is unavailable. */ }
|
catch { /* Optional display preferences. */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
function setViewMode(mode: "cards" | "list") {
|
function setViewMode(mode: "cards" | "list") {
|
||||||
@@ -96,10 +86,12 @@
|
|||||||
savePreferences();
|
savePreferences();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openWorkspaceDialog() {
|
function openWorkspaceDialog(edit = false) {
|
||||||
workspaceName = "";
|
const workspace = edit ? workspaces.find(item => item.id === selectedWorkspace) : undefined;
|
||||||
|
editingWorkspaceId = workspace?.id ?? "";
|
||||||
|
workspaceName = workspace?.name ?? "";
|
||||||
workspaceError = "";
|
workspaceError = "";
|
||||||
workspaceSelection = new Set();
|
workspaceSelection = new Set(workspace?.repositories ?? []);
|
||||||
workspaceDialog.showModal();
|
workspaceDialog.showModal();
|
||||||
requestAnimationFrame(() => workspaceInput.focus());
|
requestAnimationFrame(() => workspaceInput.focus());
|
||||||
}
|
}
|
||||||
@@ -118,27 +110,14 @@
|
|||||||
workspaceError = de ? "Bitte einen Namen eingeben." : "Please enter a name.";
|
workspaceError = de ? "Bitte einen Namen eingeben." : "Please enter a name.";
|
||||||
return;
|
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.";
|
workspaceError = de ? "Dieser Workspace existiert bereits." : "This workspace already exists.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const id = `workspace-${crypto.randomUUID()}`;
|
void onSaveWorkspace(editingWorkspaceId, name, [...workspaceSelection]);
|
||||||
workspaces = [...workspaces, { id, name }];
|
|
||||||
assignments = { ...assignments, ...Object.fromEntries([...workspaceSelection].map((path) => [path, id])) };
|
|
||||||
selectedWorkspace = id;
|
|
||||||
savePreferences();
|
|
||||||
workspaceDialog.close();
|
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 openCard(path: string) { if (!isBusy) onOpen(path); }
|
||||||
function closeCard(event: MouseEvent, path: string) { event.stopPropagation(); if (!isBusy) onClose(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); }
|
function favoriteCard(event: MouseEvent, path: string) { event.stopPropagation(); if (!isBusy) onFavorite(path); }
|
||||||
@@ -188,16 +167,18 @@
|
|||||||
value={selectedWorkspace}
|
value={selectedWorkspace}
|
||||||
options={workspaceOptions}
|
options={workspaceOptions}
|
||||||
ariaLabel={de ? "Workspace auswählen" : "Select workspace"}
|
ariaLabel={de ? "Workspace auswählen" : "Select workspace"}
|
||||||
onChange={(value) => { selectedWorkspace = value; }}
|
disabled={isBusy}
|
||||||
|
onChange={onWorkspaceChange}
|
||||||
/>
|
/>
|
||||||
{#if selectedWorkspace}
|
{#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}
|
{/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>
|
</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"}>
|
<div class="dashboard-content" class:table-view={viewMode === "list"}>
|
||||||
{#each categories as category (category.id)}
|
{#each categories as category (category.id)}
|
||||||
@@ -230,7 +211,7 @@
|
|||||||
</button>
|
</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>
|
<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 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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -242,21 +223,21 @@
|
|||||||
</section>
|
</section>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</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>
|
</section>
|
||||||
|
|
||||||
<dialog class="workspace-dialog" bind:this={workspaceDialog} aria-labelledby="workspace-title">
|
<dialog class="workspace-dialog" bind:this={workspaceDialog} aria-labelledby="workspace-title">
|
||||||
<form onsubmit={createWorkspace}>
|
<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>
|
<label for="workspace-name">Name</label>
|
||||||
<input id="workspace-name" bind:this={workspaceInput} bind:value={workspaceName} maxlength="64" autocomplete="off" aria-invalid={Boolean(workspaceError)} />
|
<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 workspaceError}<p class="dialog-error" role="alert">{workspaceError}</p>{/if}
|
||||||
{#if repos.length > 0}
|
{#if repos.length > 0}
|
||||||
<fieldset><legend>{de ? "Repositories zuordnen" : "Assign repositories"}</legend><div class="workspace-repos">
|
<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><input type="checkbox" checked={workspaceSelection.has(repo.path)} onchange={() => toggleWorkspaceRepo(repo.path)} /><span>{repo.name}</span><small>{repo.path}</small></label>{/each}
|
{#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>
|
</div></fieldset>
|
||||||
{/if}
|
{/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>
|
</form>
|
||||||
</dialog>
|
</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)}
|
.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)}
|
.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)}
|
.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: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}}
|
@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)}
|
.view-switch{display:flex;flex:0 0 auto;height:30px;border:1px solid var(--color-border-input);background:var(--app-input-bg)}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user