Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 15 additions & 1 deletion TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
53 changes: 53 additions & 0 deletions crates/strand-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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<Vec<Stash>> {
Ok(Repo::discover(&path)?.stash_list()?)
Expand Down Expand Up @@ -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();
}
}
1 change: 1 addition & 0 deletions crates/strand-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 21 additions & 2 deletions ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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],
Expand Down
90 changes: 90 additions & 0 deletions ui/src/lib/codeWorkspace.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading
Loading