diff --git a/README.md b/README.md index 6095a3c..2c819d7 100644 --- a/README.md +++ b/README.md @@ -80,8 +80,9 @@ keyboard alone, and the mouse stays first-class. vertical icon rail or horizontal toolbar tabs, your pick in Appearance) persisted across launches, saveable as named **workspaces** (the repos behind one product — open a workspace to focus the rail on just those repos, close it - to return to your default set; a manage dialog curates each, and creating, -switching, and managing are all in ⌘K), native macOS + to return to your default set; a manage dialog curates each; creating, + switching, and managing are all in ⌘K, including importing a VS Code + `.code-workspace`), native macOS menubar, open in your editor or terminal, settings (⌘,) for appearance / diff / git / integrations / AI, in-app updates. - **AI commit messages** — suggest subject + body from staged changes via diff --git a/ROADMAP.md b/ROADMAP.md index f8f1860..e72ce98 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -841,6 +841,27 @@ Verified: `tsc`, `vitest` (158), `vite build`. Remaining Phase 3: `.code-workspace` import + the Workspace Review follow-ups (tracked in TASKS). +**`.code-workspace` import (2026-07-02):** A VS Code multi-root workspace +file now imports as a Strand workspace — palette "Import .code-workspace…" +or the manager's new "Import .code-workspace…" row. Parsing is pure, +unit-tested TS (`lib/codeWorkspace.ts`): JSONC-tolerant (string-aware +comment/trailing-comma stripping), local `path` folders only (remote `uri` +entries ignored), relative paths joined against the file's directory and +each candidate validated through `repoOpen`, whose canonical `meta.path` is +what gets stored — the join never fabricates a re-spelled path (the Windows +duplicate-tab rule). The file itself is read by a new deliberately narrow +IPC command (`workspace_file_read`: name must end `.code-workspace`, ≤1 MB +— the webview gets no generic file reader out of this). Non-repo folders +are skipped and reported (toast / manager message); the import only errors +when nothing resolves. Verified with `cargo test -p strand-tauri` (+1 gate +test), `clippy`, `tsc`, `vitest` (171, +13 `codeWorkspace`), `vite build`, +and an end-to-end pass against the **running Tauri app** over WebView2 CDP: +a JSONC fixture with two real repos + a non-repo + a remote uri imported as +"acme" (2 added, 1 skipped, uri ignored), the workspace opened with members +tabbed, the all-non-repo fixture threw without creating anything, and the +backend refused a non-workspace path. Remaining Phase 3: the Workspace +Review follow-ups (tracked in TASKS). + --- ## 1.0 — Stable (≈ 20 weeks) diff --git a/TASKS.md b/TASKS.md index 651bb74..f509670 100644 --- a/TASKS.md +++ b/TASKS.md @@ -451,7 +451,21 @@ Legend: ☐ not started · ◐ in progress · ☑ done · ✗ blocked manager grew its own create path (a "+ New workspace" row in the list; creating was switcher-menu-only before): the workspace is spawned with a placeholder name and the name field autofocuses with the text selected. - - ☐ `.code-workspace` import + - ☑ `.code-workspace` import (2026-07-02): palette "Import + .code-workspace…" and an "Import .code-workspace…" row in the manager's + workspace list create a named workspace from a VS Code workspace file. + Parsing is pure TS (`lib/codeWorkspace.ts`, unit-tested): JSONC-tolerant + (string-aware comment + trailing-comma stripping), local `path` entries + only (remote `uri` ignored), relative paths joined against the file's + directory *without* canonicalizing — each folder is validated through + `repoOpen` and the canonical `meta.path` is what gets stored (the + Windows re-spelling rule). File reading is a new gated IPC command + (`workspace_file_read` in `strand-tauri` — name must end + `.code-workspace`, ≤1 MB, so it can't be repurposed as a generic file + reader; unit-tested). `useWorkspaces.importCodeWorkspace` returns + added/skipped; non-repo folders are reported (toast / manager message), + only zero-repos is an error. Verified end-to-end against the running + Tauri app over CDP (import → open → members tabbed; error + gate probes). - ☐ Workspace Review follow-ups: hunk-level stage/discard, notes + repo-grouped feedback export, ⌘F across member pools, per-worktree members. diff --git a/crates/strand-tauri/src/commands.rs b/crates/strand-tauri/src/commands.rs index e5524b4..fce04ef 100644 --- a/crates/strand-tauri/src/commands.rs +++ b/crates/strand-tauri/src/commands.rs @@ -746,6 +746,29 @@ pub fn git_set_global_identity(name: String, email: String) -> CmdResult<()> { Ok(gitconfig::set_global_identity(&name, &email)?) } +/// Read a VS Code `.code-workspace` file for the workspace importer. This is +/// the one IPC path that reads an arbitrary user-picked file, so it's gated +/// hard: the name must end in `.code-workspace` and the file must be small +/// (they're hand-sized JSON documents) — it can't be repurposed as a generic +/// file reader from the webview. +#[tauri::command(async)] +pub fn workspace_file_read(path: String) -> CmdResult { + const MAX_LEN: u64 = 1024 * 1024; + let p = std::path::Path::new(&path); + let ext_ok = p + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.to_ascii_lowercase().ends_with(".code-workspace")); + if !ext_ok { + return Err(CmdError { message: "not a .code-workspace file".into() }); + } + let meta = std::fs::metadata(p).map_err(|e| CmdError { message: e.to_string() })?; + if meta.len() > MAX_LEN { + return Err(CmdError { message: "workspace file too large (max 1 MB)".into() }); + } + std::fs::read_to_string(p).map_err(|e| CmdError { message: e.to_string() }) +} + #[tauri::command(async)] pub fn repo_stash_list(path: String) -> CmdResult> { Ok(Repo::discover(&path)?.stash_list()?) @@ -872,3 +895,33 @@ impl CmdError { Self { message } } } + +#[cfg(test)] +mod tests { + use super::workspace_file_read; + + #[test] + fn workspace_file_read_gates_extension_and_size() { + let dir = std::env::temp_dir().join(format!("strand-wsread-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + + // Wrong extension refused, even if the content would parse. + let json = dir.join("workspace.json"); + std::fs::write(&json, "{\"folders\":[]}").unwrap(); + assert!(workspace_file_read(json.to_string_lossy().into_owned()).is_err()); + + // Right extension (case-insensitive) reads back verbatim. + let ws = dir.join("Acme.Code-Workspace"); + std::fs::write(&ws, "{\"folders\":[{\"path\":\"api\"}]}").unwrap(); + let text = workspace_file_read(ws.to_string_lossy().into_owned()).unwrap(); + assert_eq!(text, "{\"folders\":[{\"path\":\"api\"}]}"); + + // Oversized file refused before reading. + let big = dir.join("big.code-workspace"); + std::fs::write(&big, vec![b' '; 1024 * 1024 + 1]).unwrap(); + let err = workspace_file_read(big.to_string_lossy().into_owned()).unwrap_err(); + assert!(err.message.contains("too large")); + + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/crates/strand-tauri/src/main.rs b/crates/strand-tauri/src/main.rs index c99da7c..f08fead 100644 --- a/crates/strand-tauri/src/main.rs +++ b/crates/strand-tauri/src/main.rs @@ -136,6 +136,7 @@ fn main() { commands::repo_open_in_terminal, commands::git_global_identity, commands::git_set_global_identity, + commands::workspace_file_read, commands::repo_stash_list, commands::repo_stash_save, commands::repo_stash_snapshot, diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 8a59487..0b6fb8c 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -20,7 +20,7 @@ import { DEFAULT_WORKSPACE_ID, useWorkspaces } from './stores/workspaces'; import { useWorkspaceReview } from './stores/workspaceReview'; import { useUpdates } from './stores/updates'; import { accentHueForColor, groupTabs, repoFamilyName, workspaceMemberSet } from './lib/repoIdentity'; -import { pickRepoDirectories } from './lib/dialog'; +import { pickCodeWorkspaceFile, pickRepoDirectories } from './lib/dialog'; import { editorTemplate, osType, terminalTemplate } from './lib/integrations'; import { concatPatches, patchesToMarkdown } from './lib/patchExport'; import { buildReviewFeedback, collectFeedbackFiles } from './lib/reviewExport'; @@ -444,6 +444,24 @@ export function App() { await openMany(await pickRepoDirectories()); }, [openMany]); + // Import a VS Code .code-workspace as a named workspace: pick the file, + // let the store parse/validate it, then open the result. Folders that + // aren't repos are reported in the toast, not fatal. + const importCodeWorkspaceFlow = useCallback(async () => { + const file = await pickCodeWorkspaceFile(); + if (!file) return; + try { + const r = await useWorkspaces.getState().importCodeWorkspace(file); + await useWorkspaces.getState().openWorkspace(r.id); + const skipped = r.skipped.length + ? ` — ${r.skipped.length} folder${r.skipped.length === 1 ? '' : 's'} skipped (not a repository)` + : ''; + showToast(`Imported “${r.name}” with ${r.added} repositor${r.added === 1 ? 'y' : 'ies'}${skipped}`); + } catch (e) { + showToast(`Import failed: ${errMessage(e)}`); + } + }, [showToast]); + // Open the tile/tab icon-customization dialog for a repo. Shared by the repo // rail and the toolbar tab strip (whichever `repoNav` selects). const openIconDialog = useCallback((path: string) => { @@ -977,6 +995,7 @@ export function App() { { id: 'switch-repo', label: 'Switch repository…', group: 'Actions', shortcut: keyHint('switch-repo'), keywords: 'switch repo repository jump active picker quick open', run: () => setRepoSwitcherOpen(true) }, { id: 'workspace-new', label: 'New workspace…', group: 'Actions', keywords: 'workspace create group repositories multi repo', run: () => setWorkspaceManagerOpen('create') }, { id: 'workspace-manage', label: 'Manage workspaces…', group: 'Actions', keywords: 'workspace edit curate repositories add remove rename delete', run: () => setWorkspaceManagerOpen(true) }, + { id: 'workspace-import', label: 'Import .code-workspace…', group: 'Actions', keywords: 'workspace import vscode visual studio code folders multi root', run: () => { void importCodeWorkspaceFlow(); } }, ]; // Repo-scoped actions only make sense — and only succeed — with a repo // open, so don't surface them (the network ones would fail confusingly). @@ -1163,7 +1182,7 @@ export function App() { baseline, setBaseline, clearBaseline, stageReviewed, commits, resetTo, unstagedCount, stagedCount, baselineDiffCount, copyDiffs, reviewNoteCount, clearReviewNotes, keyHint, platform, cycleTab, - workspaces, activeWorkspaceId]); + workspaces, activeWorkspaceId, importCodeWorkspaceFlow]); const rootStyle = { '--font-ui': FONTS.ui[uiFont], diff --git a/ui/src/lib/codeWorkspace.test.ts b/ui/src/lib/codeWorkspace.test.ts new file mode 100644 index 0000000..2600d14 --- /dev/null +++ b/ui/src/lib/codeWorkspace.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; + +import { + dirnameOf, + parseCodeWorkspace, + resolveWorkspaceFolder, + workspaceNameFromFile, +} from './codeWorkspace'; + +describe('parseCodeWorkspace', () => { + it('reads folder paths from a plain document', () => { + const r = parseCodeWorkspace('{"folders":[{"path":"api"},{"path":"../web"}]}'); + expect(r).toEqual({ folders: ['api', '../web'], ignored: 0 }); + }); + + it('tolerates JSONC: comments and trailing commas', () => { + const text = `{ + // the repos behind the product + "folders": [ + { "path": "api" }, /* main service */ + { "path": "web", }, + ], + "settings": {}, + }`; + expect(parseCodeWorkspace(text).folders).toEqual(['api', 'web']); + }); + + it('does not treat // inside strings as a comment', () => { + const text = '{"folders":[{"path":"api","name":"http://example.com"}]}'; + expect(parseCodeWorkspace(text).folders).toEqual(['api']); + }); + + it('counts uri-only (remote) and malformed entries as ignored', () => { + const text = + '{"folders":[{"uri":"vscode-remote://ssh/x"},{"path":"api"},{"name":"no path"},42]}'; + const r = parseCodeWorkspace(text); + expect(r.folders).toEqual(['api']); + expect(r.ignored).toBe(3); + }); + + it('handles a missing or non-array folders key as zero folders', () => { + expect(parseCodeWorkspace('{}')).toEqual({ folders: [], ignored: 0 }); + expect(parseCodeWorkspace('{"folders":"nope"}')).toEqual({ folders: [], ignored: 0 }); + }); + + it('throws on unparseable JSON', () => { + expect(() => parseCodeWorkspace('{folders:')).toThrow(/valid .code-workspace/); + }); + + it('survives escaped quotes inside strings', () => { + const text = '{"folders":[{"path":"api","name":"say \\"hi\\" // not a comment"}]}'; + expect(parseCodeWorkspace(text).folders).toEqual(['api']); + }); +}); + +describe('resolveWorkspaceFolder', () => { + it('passes absolute paths through untouched', () => { + expect(resolveWorkspaceFolder('D:\\proj', 'C:\\other\\repo')).toBe('C:\\other\\repo'); + expect(resolveWorkspaceFolder('/home/me', '/srv/repo')).toBe('/srv/repo'); + expect(resolveWorkspaceFolder('D:\\proj', '\\\\server\\share')).toBe('\\\\server\\share'); + }); + + it('joins relative paths with the base directory separator style', () => { + expect(resolveWorkspaceFolder('D:\\proj', 'api')).toBe('D:\\proj\\api'); + expect(resolveWorkspaceFolder('D:\\proj', './api')).toBe('D:\\proj\\api'); + expect(resolveWorkspaceFolder('D:\\proj', 'apps/web')).toBe('D:\\proj\\apps\\web'); + expect(resolveWorkspaceFolder('/home/me/proj', 'api')).toBe('/home/me/proj/api'); + }); + + it('resolves "." to the file directory itself', () => { + expect(resolveWorkspaceFolder('D:\\proj\\', '.')).toBe('D:\\proj'); + }); + + it('keeps ".." segments for the OS to resolve', () => { + expect(resolveWorkspaceFolder('D:\\proj\\meta', '../api')).toBe('D:\\proj\\meta\\..\\api'); + }); +}); + +describe('file-path helpers', () => { + it('dirnameOf handles both separators', () => { + expect(dirnameOf('D:\\proj\\acme.code-workspace')).toBe('D:\\proj'); + expect(dirnameOf('/home/me/acme.code-workspace')).toBe('/home/me'); + }); + + it('workspaceNameFromFile strips the extension case-insensitively', () => { + expect(workspaceNameFromFile('D:\\proj\\acme.code-workspace')).toBe('acme'); + expect(workspaceNameFromFile('/x/Client Stack.CODE-WORKSPACE')).toBe('Client Stack'); + expect(workspaceNameFromFile('/x/.code-workspace')).toBe('Workspace'); + }); +}); diff --git a/ui/src/lib/codeWorkspace.ts b/ui/src/lib/codeWorkspace.ts new file mode 100644 index 0000000..a8d6665 --- /dev/null +++ b/ui/src/lib/codeWorkspace.ts @@ -0,0 +1,141 @@ +/** + * VS Code `.code-workspace` parsing for the workspace importer. + * + * The format is JSONC — JSON that tolerates `//` and `/* *\/` comments and + * trailing commas — with a `folders` array of `{ path }` (local) or `{ uri }` + * (remote) entries; relative paths resolve against the file's directory. + * Parsing is pure string work here (unit-tested); file reading and repo + * validation happen elsewhere. + */ + +/** Strip `//` and `/* *\/` comments, string-aware (a `//` inside a quoted + * value — `"http://x"` — survives). */ +function stripComments(text: string): string { + let out = ''; + let i = 0; + let inStr = false; + while (i < text.length) { + const c = text[i]; + if (inStr) { + out += c; + if (c === '\\' && i + 1 < text.length) { + out += text[i + 1]; + i += 2; + continue; + } + if (c === '"') inStr = false; + i++; + continue; + } + if (c === '"') { + inStr = true; + out += c; + i++; + continue; + } + if (c === '/' && text[i + 1] === '/') { + while (i < text.length && text[i] !== '\n') i++; + continue; + } + if (c === '/' && text[i + 1] === '*') { + i += 2; + while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++; + i += 2; + continue; + } + out += c; + i++; + } + return out; +} + +/** Drop commas whose next non-whitespace char closes the container — the + * trailing commas JSONC allows and `JSON.parse` doesn't. String-aware. */ +function stripTrailingCommas(text: string): string { + let out = ''; + let inStr = false; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (inStr) { + out += c; + if (c === '\\') { + out += text[++i] ?? ''; + continue; + } + if (c === '"') inStr = false; + continue; + } + if (c === '"') { + inStr = true; + out += c; + continue; + } + if (c === ',') { + let j = i + 1; + while (j < text.length && /\s/.test(text[j])) j++; + if (text[j] === '}' || text[j] === ']') continue; + } + out += c; + } + return out; +} + +export interface CodeWorkspaceFolders { + /** The local folder paths (`path` entries), still unresolved. */ + folders: string[]; + /** Entries with no usable local path (remote `uri` folders, malformed). */ + ignored: number; +} + +/** Parse a `.code-workspace` document into its local folder paths. Throws on + * unparseable JSON; a missing/empty `folders` array is just zero folders. */ +export function parseCodeWorkspace(text: string): CodeWorkspaceFolders { + let doc: unknown; + try { + doc = JSON.parse(stripTrailingCommas(stripComments(text))); + } catch { + throw new Error('not a valid .code-workspace file (unparseable JSON)'); + } + const folders = (doc as { folders?: unknown } | null)?.folders; + if (!Array.isArray(folders)) return { folders: [], ignored: 0 }; + const out: string[] = []; + let ignored = 0; + for (const f of folders) { + const path = typeof f === 'object' && f !== null ? (f as { path?: unknown }).path : undefined; + if (typeof path === 'string' && path.trim()) out.push(path.trim()); + else ignored++; + } + return { folders: out, ignored }; +} + +const isAbsolute = (p: string): boolean => + /^[A-Za-z]:[\\/]/.test(p) || p.startsWith('/') || p.startsWith('\\\\'); + +/** + * Resolve a folder entry against the workspace file's directory. Only a + * join — no canonicalization (`repoOpen` canonicalizes on the Rust side, + * and its `meta.path` is what gets stored; fabricating a re-spelled path + * here is the Windows duplicate-tab trap). + */ +export function resolveWorkspaceFolder(fileDir: string, folder: string): string { + if (isAbsolute(folder)) return folder; + const base = fileDir.replace(/[\\/]+$/, ''); + if (folder === '.') return base; + const sep = base.includes('\\') ? '\\' : '/'; + let rel = folder.replace(/^\.\//, ''); + if (sep === '\\') rel = rel.replace(/\//g, '\\'); + return `${base}${sep}${rel}`; +} + +/** The directory holding `filePath` (both separators; no trailing slash). */ +export function dirnameOf(filePath: string): string { + const i = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\')); + return i > 0 ? filePath.slice(0, i) : filePath; +} + +/** Workspace display name: the file's basename minus the extension. */ +export function workspaceNameFromFile(filePath: string): string { + const i = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\')); + const base = filePath.slice(i + 1); + return base.replace(/\.code-workspace$/i, '').trim() || 'Workspace'; +} diff --git a/ui/src/lib/dialog.ts b/ui/src/lib/dialog.ts index d3164dd..161ce0e 100644 --- a/ui/src/lib/dialog.ts +++ b/ui/src/lib/dialog.ts @@ -35,3 +35,18 @@ export async function pickDirectory( const selected = await openDialog({ directory: true, multiple: false, title, defaultPath }); return typeof selected === 'string' ? selected : null; } + +/** + * Show the native file picker filtered to VS Code `.code-workspace` files + * (the workspace importer). Returns the chosen path, or `null` if the user + * cancelled or we're not in Tauri. + */ +export async function pickCodeWorkspaceFile(): Promise { + if (!isTauri()) return null; + const selected = await openDialog({ + multiple: false, + title: 'Import .code-workspace', + filters: [{ name: 'VS Code workspace', extensions: ['code-workspace'] }], + }); + return typeof selected === 'string' ? selected : null; +} diff --git a/ui/src/lib/tauri.ts b/ui/src/lib/tauri.ts index dcd1cf3..d1a552c 100644 --- a/ui/src/lib/tauri.ts +++ b/ui/src/lib/tauri.ts @@ -294,6 +294,8 @@ export const tauri = { gitGlobalIdentity: () => invoke('git_global_identity'), gitSetGlobalIdentity: (name: string, email: string) => invoke('git_set_global_identity', { name, email }), + /** Raw text of a `.code-workspace` file (extension + 1 MB gated backend-side). */ + workspaceFileRead: (path: string) => invoke('workspace_file_read', { path }), repoStashList: (path: string) => invoke('repo_stash_list', { path }), repoStashSave: ( path: string, diff --git a/ui/src/stores/workspaces.ts b/ui/src/stores/workspaces.ts index a33d19f..c4de30c 100644 --- a/ui/src/stores/workspaces.ts +++ b/ui/src/stores/workspaces.ts @@ -1,7 +1,14 @@ import { create } from 'zustand'; -import { workspacesDb } from '../lib/db'; -import { pathKey, workspaceMemberSet } from '../lib/repoIdentity'; +import { + dirnameOf, + parseCodeWorkspace, + resolveWorkspaceFolder, + workspaceNameFromFile, +} from '../lib/codeWorkspace'; +import { recents as recentsDb, workspacesDb } from '../lib/db'; +import { pathKey, repoFamilyName, workspaceMemberSet } from '../lib/repoIdentity'; +import { tauri } from '../lib/tauri'; import type { Workspace } from '../lib/types'; import { useRepo, type RepoTab } from './repo'; @@ -61,6 +68,18 @@ interface WorkspacesState { /** Create a named workspace from `repoPaths` (deduped) and return its id. */ create(name: string, repoPaths: string[]): Promise; + /** + * Import a VS Code `.code-workspace` file as a new named workspace: parse + * its `folders` (JSONC-tolerant), resolve them against the file's + * directory, validate each through `repoOpen` (which also canonicalizes — + * the returned `meta.path` is what gets stored), and create the workspace + * from the repos that resolve. Folders that aren't git repositories come + * back in `skipped` rather than failing the import; it only throws when + * the file is unreadable/unparseable or *no* folder is a repo. + */ + importCodeWorkspace( + filePath: string, + ): Promise<{ id: string; name: string; added: number; skipped: string[] }>; /** Rename a workspace (no-op on Default). */ rename(id: string, name: string): Promise; /** @@ -303,6 +322,38 @@ export const useWorkspaces = create((set, get) => { return ws.id; }, + async importCodeWorkspace(filePath) { + const text = await tauri.workspaceFileRead(filePath); + const parsed = parseCodeWorkspace(text); + const dir = dirnameOf(filePath); + const added: string[] = []; + const skipped: string[] = []; + for (const folder of parsed.folders) { + const candidate = resolveWorkspaceFolder(dir, folder); + try { + const meta = await tauri.repoOpen(candidate); + if (!added.some((p) => pathKey(p) === pathKey(meta.path))) { + added.push(meta.path); + // Record in recents so the repo shows a proper name everywhere + // from now on (the manager's add-from-disk does the same). + void recentsDb.touch(meta.path, repoFamilyName(meta)).catch(() => undefined); + } + } catch { + skipped.push(folder); + } + } + if (added.length === 0) { + throw new Error( + parsed.folders.length === 0 + ? 'the file lists no folders' + : 'none of its folders is a git repository', + ); + } + const name = workspaceNameFromFile(filePath); + const id = await get().create(name, added); + return { id, name, added: added.length, skipped }; + }, + async rename(id, name) { if (id === DEFAULT_WORKSPACE_ID) return; const trimmed = name.trim(); diff --git a/ui/src/views/WorkspaceManagerDialog.tsx b/ui/src/views/WorkspaceManagerDialog.tsx index 8f7ed8e..4ef46b6 100644 --- a/ui/src/views/WorkspaceManagerDialog.tsx +++ b/ui/src/views/WorkspaceManagerDialog.tsx @@ -2,9 +2,9 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { Icon } from '../components/Icon'; import { recents as recentsDb } from '../lib/db'; -import { pickRepoDirectories } from '../lib/dialog'; +import { pickCodeWorkspaceFile, pickRepoDirectories } from '../lib/dialog'; import { pathKey, pathLeaf, repoFamilyName } from '../lib/repoIdentity'; -import { tauri } from '../lib/tauri'; +import { errMessage, tauri } from '../lib/tauri'; import { useRepo } from '../stores/repo'; import { DEFAULT_WORKSPACE_ID, useWorkspaces } from '../stores/workspaces'; @@ -27,6 +27,7 @@ export function WorkspaceManagerDialog({ initialCreate, onClose }: { initialCrea const workspaces = useWorkspaces((s) => s.workspaces); const activeId = useWorkspaces((s) => s.activeWorkspaceId); const create = useWorkspaces((s) => s.create); + const importWorkspace = useWorkspaces((s) => s.importCodeWorkspace); const rename = useWorkspaces((s) => s.rename); const remove = useWorkspaces((s) => s.remove); const addRepo = useWorkspaces((s) => s.addRepo); @@ -117,6 +118,24 @@ export function WorkspaceManagerDialog({ initialCreate, onClose }: { initialCrea } }, []); + // Import a .code-workspace as a new workspace and select it for curation; + // skipped folders surface in the same message slot as add-from-disk errors. + const importFromFile = async () => { + const file = await pickCodeWorkspaceFile(); + if (!file) return; + setAddError(null); + try { + const r = await importWorkspace(file); + setSelectedId(r.id); + if (r.skipped.length > 0) { + setAddError(`Skipped (not a git repository): ${r.skipped.join(', ')}`); + } + void refreshRecents(); + } catch (e) { + setAddError(`Import failed: ${errMessage(e)}`); + } + }; + // Add repos picked from the native folder dialog. Each pick is validated + // canonicalized through `repo_open` (membership keys on canonical paths), // and recorded in recents so it shows up with a proper name from now on. @@ -179,6 +198,15 @@ export function WorkspaceManagerDialog({ initialCreate, onClose }: { initialCrea New workspace + {/* Right: repos of the selected workspace */}