Skip to content
Open
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
6 changes: 6 additions & 0 deletions docs/runtime/worktree.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,9 @@ Example layout:
improved-auth-ux/
fix-ci-flakes/
```

## VS Code workspace file sync

To browse all of a project's worktrees from one VS Code or code-server window, set a `.code-workspace` file path per project in Settings → Runtimes (select the project scope). Xum keeps that file's folder list in sync as worktree workspaces are created, renamed, archived, and deleted.

Xum only manages folder entries under the project's worktree directory; folders you add yourself and everything outside the `folders` array (comments, `settings`/`extensions` blocks) are left untouched. When Xum adds or removes an entry it rewrites the `folders` array itself, so comments placed inside that array are not preserved.
3 changes: 3 additions & 0 deletions src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ function createProjectContextValue(
updateDisplayName: () => resolveVoidResult(),
updateColor: () => resolveVoidResult(),
updateCustomInstructions: () => resolveVoidResult(),
updateCodeWorkspaceSyncPath: () => resolveVoidResult(),
assignWorkspaceToSubProject: () => resolveVoidResult(),
hasAnyProject: false,
resolveNewChatProjectPath: () => null,
Expand Down Expand Up @@ -1334,6 +1335,7 @@ describe("ProjectSidebar multi-project completed-subagent toggles", () => {
updateDisplayName: () => resolveVoidResult(),
updateColor: () => resolveVoidResult(),
updateCustomInstructions: () => resolveVoidResult(),
updateCodeWorkspaceSyncPath: () => resolveVoidResult(),
assignWorkspaceToSubProject: () => resolveVoidResult(),
hasAnyProject: true,
resolveNewChatProjectPath: () => "/projects/demo-project",
Expand Down Expand Up @@ -1925,6 +1927,7 @@ describe("ProjectSidebar multi-project completed-subagent toggles", () => {
updateDisplayName: () => resolveVoidResult(),
updateColor: () => resolveVoidResult(),
updateCustomInstructions: () => resolveVoidResult(),
updateCodeWorkspaceSyncPath: () => resolveVoidResult(),
assignWorkspaceToSubProject: () => resolveVoidResult(),
hasAnyProject: true,
resolveNewChatProjectPath: () => "/projects/demo-project",
Expand Down
20 changes: 20 additions & 0 deletions src/browser/contexts/ProjectContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ export interface ProjectContext {
projectPath: string,
customInstructions: string | null
) => Promise<Result<void>>;
updateCodeWorkspaceSyncPath: (
projectPath: string,
codeWorkspaceSyncPath: string | null
) => Promise<Result<void>>;

assignWorkspaceToSubProject: (
projectPath: string,
Expand Down Expand Up @@ -621,6 +625,21 @@ export function ProjectProvider(props: { children: ReactNode }) {
updateDisplayName,
updateColor,
updateCustomInstructions,
// Defined inline (not useCallback): the repo bans new manual useCallback
// memoization, and inlining keeps exhaustive-deps satisfied via `api`.
updateCodeWorkspaceSyncPath: async (
projectPath: string,
codeWorkspaceSyncPath: string | null
): Promise<Result<void>> => {
if (!api) return { success: false, error: "API not connected" };
try {
await api.projects.setCodeWorkspaceSyncPath({ projectPath, codeWorkspaceSyncPath });
await refreshProjects();
return { success: true, data: undefined };
} catch (error) {
return { success: false, error: getErrorMessage(error) };
}
},
assignWorkspaceToSubProject,
}),
[
Expand All @@ -647,6 +666,7 @@ export function ProjectProvider(props: { children: ReactNode }) {
updateDisplayName,
updateColor,
updateCustomInstructions,
api,
assignWorkspaceToSubProject,
]
);
Expand Down
87 changes: 87 additions & 0 deletions src/browser/features/Settings/Sections/RuntimesSection.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { useEffect, useRef, useState } from "react";
import { AlertTriangle, Loader2 } from "lucide-react";

import { Button } from "@/browser/components/Button/Button";
import { Input } from "@/browser/components/Input/Input";
import { getErrorMessage } from "@/common/utils/errors";

import {
CoderWorkspaceForm,
resolveCoderAvailability,
Expand Down Expand Up @@ -134,6 +138,85 @@ function deriveProjectOverrideState(
};
}

/**
* Per-project opt-in path of a VS Code .code-workspace file that xum keeps in
* sync with the project's active worktrees (issue #3722). Parent keys this by
* project path so drafts reset on scope switches.
*/
function CodeWorkspaceSyncField(props: { projectPath: string }) {
const { userProjects, updateCodeWorkspaceSyncPath } = useProjectContext();
const savedPath = userProjects.get(props.projectPath)?.codeWorkspaceSyncPath ?? "";
// null = untouched: the input shows the saved value.
const [draft, setDraft] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);

const draftValue = draft ?? savedPath;
const isDirty = draftValue.trim() !== savedPath;

// DOM attributes must not receive promise-returning handlers
// (@typescript-eslint/no-misused-promises), so instead of awaiting inline,
// unexpected rejections are explicitly routed into the visible error state.
const saveDraft = () => {
void handleSave().catch((saveError: unknown) => {
setError(getErrorMessage(saveError));
setSaving(false);
});
};

const handleSave = async () => {
setSaving(true);
setError(null);
const result = await updateCodeWorkspaceSyncPath(
props.projectPath,
draftValue.trim() ? draftValue.trim() : null
);
if (result.success) {
setDraft(null);
} else {
setError(result.error ?? "Failed to save workspace file path");
}
setSaving(false);
};

return (
<div className="border-border-light bg-background-secondary rounded-md border px-3 py-2">
<div className="text-foreground text-sm">VS Code workspace file</div>
<div className="text-muted text-xs">
Path of a <code className="text-accent">.code-workspace</code> file kept in sync with this
project&apos;s active worktrees (absolute, <code className="text-accent">~</code>, or
relative to the project). Leave empty to disable.
</div>
<div className="mt-2 flex items-center gap-2">
<Input
value={draftValue}
onChange={(event) => {
setDraft(event.target.value);
}}
onKeyDown={(event) => {
if (event.key === "Enter" && isDirty && !saving) {
event.preventDefault();
saveDraft();
}
}}
disabled={saving}
placeholder="e.g. ~/my-project.code-workspace"
aria-label="VS Code workspace file path"
className="max-w-[360px] min-w-0"
/>
<Button onClick={saveDraft} disabled={!isDirty || saving} className="shrink-0">
{saving ? "Saving..." : "Save"}
</Button>
</div>
{error && (
<div className="bg-destructive/10 text-destructive mt-2 rounded-md px-3 py-2 text-sm">
{error}
</div>
)}
</div>
);
}

export function RuntimesSection() {
const { api } = useAPI();
const { userProjects, refreshProjects } = useProjectContext();
Expand Down Expand Up @@ -518,6 +601,10 @@ export function RuntimesSection() {
/>
</div>
) : null}

{selectedProjectPath ? (
<CodeWorkspaceSyncField key={selectedProjectPath} projectPath={selectedProjectPath} />
) : null}
</div>

<div className="space-y-4">
Expand Down
9 changes: 9 additions & 0 deletions src/common/orpc/schemas/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -802,6 +802,15 @@ export const projects = {
.passthrough(),
output: z.void(),
},
setCodeWorkspaceSyncPath: {
input: z
.object({
projectPath: z.string(),
codeWorkspaceSyncPath: z.string().nullish(),
})
.passthrough(),
output: z.void(),
},
mcp: {
list: {
input: z.object({ projectPath: z.string() }),
Expand Down
4 changes: 4 additions & 0 deletions src/common/schemas/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,10 @@ export const ProjectConfigSchema = z.object({
description:
"Custom system prompt appended for every workspace in this project (Settings → Instructions)",
}),
codeWorkspaceSyncPath: z.string().optional().meta({
description:
"Path to a VS Code .code-workspace file kept in sync with this project's active worktrees (relative paths resolve against the project root). Unset = sync disabled.",
Comment thread
ibetitsmike marked this conversation as resolved.
}),
});

export type WorktreeArchiveSnapshotProject = z.infer<typeof WorktreeArchiveSnapshotProjectSchema>;
Expand Down
22 changes: 22 additions & 0 deletions src/node/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -729,6 +729,28 @@ describe("Config", () => {
"Keep this guidance."
);
});

it("discards malformed non-string codeWorkspaceSyncPath and keeps valid ones", () => {
const configFile = path.join(tempDir, "config.json");
fs.writeFileSync(
configFile,
JSON.stringify({
projects: [
["/home/user/number", { workspaces: [], codeWorkspaceSyncPath: 42 }],
["/home/user/blank", { workspaces: [], codeWorkspaceSyncPath: " " }],
["/home/user/valid", { workspaces: [], codeWorkspaceSyncPath: "a.code-workspace" }],
],
})
);

const loaded = config.loadConfigOrDefault();

expect(loaded.projects.get("/home/user/number")?.codeWorkspaceSyncPath).toBeUndefined();
expect(loaded.projects.get("/home/user/blank")?.codeWorkspaceSyncPath).toBeUndefined();
expect(loaded.projects.get("/home/user/valid")?.codeWorkspaceSyncPath).toBe(
"a.code-workspace"
);
});
});

describe("legacy workflow schedule cleanup", () => {
Expand Down
8 changes: 8 additions & 0 deletions src/node/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,7 @@ function normalizeProjectRuntimeSettings(projectConfig: ProjectConfig): ProjectC
runtimeOverridesEnabled?: unknown;
projectKind?: unknown;
customInstructions?: unknown;
codeWorkspaceSyncPath?: unknown;
};
const runtimeEnablement = normalizeRuntimeEnablementOverrides(record.runtimeEnablement);
const defaultRuntime = normalizeRuntimeEnablementId(record.defaultRuntime);
Expand Down Expand Up @@ -725,6 +726,13 @@ function normalizeProjectRuntimeSettings(projectConfig: ProjectConfig): ProjectC
delete next.customInstructions;
}

// Same hand-edit hazard as customInstructions above.
if (typeof record.codeWorkspaceSyncPath === "string" && record.codeWorkspaceSyncPath.trim()) {
next.codeWorkspaceSyncPath = record.codeWorkspaceSyncPath;
} else {
delete next.codeWorkspaceSyncPath;
}

// Legacy named workflow schedules are intentionally dropped while workflow
// scheduling is disabled during the explicit script_path migration.
delete (next as ProjectConfig & { workflowSchedules?: unknown }).workflowSchedules;
Expand Down
Loading
Loading