From f4a9985ae152e2a96221cc9af981d30f5d0427e4 Mon Sep 17 00:00:00 2001 From: CreatorGhost Date: Sun, 12 Jul 2026 01:28:19 +0530 Subject: [PATCH] feat(core): worktree-based workspace switching with stash-based warp Ported from upstream anomalyco/opencode#36052. --- packages/opencode/src/cli/cmd/worktree.ts | 164 ++++++++++++ .../src/control-plane/adapters/worktree.ts | 7 +- .../workspace-adapter-runtime.ts | 3 +- .../opencode/src/control-plane/workspace.ts | 10 +- packages/opencode/src/index.ts | 2 + packages/opencode/src/project/vcs.ts | 25 ++ .../instance/httpapi/groups/instance.ts | 24 ++ .../instance/httpapi/groups/workspace.ts | 1 + .../instance/httpapi/handlers/instance.ts | 10 + .../instance/httpapi/handlers/workspace.ts | 2 + packages/opencode/src/session/session.ts | 15 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 94 ++++++- packages/sdk/js/src/v2/gen/types.gen.ts | 62 +++++ packages/tui/src/app.tsx | 12 +- .../src/component/dialog-workspace-create.tsx | 246 ++++++++++-------- packages/tui/src/component/prompt/index.tsx | 7 +- .../tui/src/component/prompt/workspace.tsx | 50 +++- packages/tui/src/context/directory.ts | 15 +- packages/tui/src/context/project.tsx | 5 +- packages/tui/src/context/sdk.tsx | 17 +- packages/tui/src/context/sync.tsx | 11 +- .../tui/src/feature-plugins/home/footer.tsx | 19 +- .../src/feature-plugins/sidebar/footer.tsx | 5 +- packages/tui/src/routes/session/index.tsx | 7 + packages/tui/src/ui/dialog-select.tsx | 1 + .../cmd/tui/dialog-workspace-create.test.ts | 33 +-- 26 files changed, 642 insertions(+), 205 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/worktree.ts diff --git a/packages/opencode/src/cli/cmd/worktree.ts b/packages/opencode/src/cli/cmd/worktree.ts new file mode 100644 index 000000000000..ea063a6cbb69 --- /dev/null +++ b/packages/opencode/src/cli/cmd/worktree.ts @@ -0,0 +1,164 @@ +import type { Argv } from "yargs" +import { EOL } from "os" +import { Effect } from "effect" +import { cmd } from "./cmd" +import { effectCmd, fail } from "../effect-cmd" +import { Worktree } from "@/worktree" +import { UI } from "../ui" +import { GlobalBus, type GlobalEvent } from "@/bus/global" + +function formatTable(entries: (Omit & { branch?: string })[]): string { + const maxName = Math.max(4, ...entries.map((e) => e.name.length)) + const maxBranch = Math.max(6, ...entries.map((e) => (e.branch ?? "-").length)) + const fmt = ` ${"Name".padEnd(maxName)} ${"Branch".padEnd(maxBranch)} Directory` + const sep = " " + "─".repeat(maxName) + " " + "─".repeat(maxBranch) + " " + "─".repeat(80) + const rows = entries.map( + (e) => ` ${e.name.padEnd(maxName)} ${(e.branch ?? "-").padEnd(maxBranch)} ${e.directory}`, + ) + return [fmt, sep, ...rows].join(EOL) +} + +export const WorktreeCommand = cmd({ + command: "worktree", + describe: "manage git worktrees", + builder: (yargs: Argv) => + yargs + .command(WorktreeCreateCommand) + .command(WorktreeListCommand) + .command(WorktreeRemoveCommand) + .command(WorktreeResetCommand) + .demandCommand(), + async handler() {}, +}) + +const waitWorktreeEvent = (directory: string) => + Effect.callback<"ready" | "failed", never>((resume) => { + const handler = (event: GlobalEvent) => { + if (event.directory !== directory) return + if (event.payload.type === Worktree.Event.Ready.type) { + GlobalBus.off("event", handler) + resume(Effect.succeed("ready" as const)) + } + if (event.payload.type === Worktree.Event.Failed.type) { + GlobalBus.off("event", handler) + resume(Effect.succeed("failed" as const)) + } + } + + GlobalBus.on("event", handler) + return Effect.sync(() => GlobalBus.off("event", handler)) + }) + +export const WorktreeCreateCommand = effectCmd({ + command: "create", + describe: "create a worktree", + builder: (yargs) => + yargs + .option("name", { + describe: "worktree name (slug; generated if omitted)", + type: "string", + }) + .option("start-command", { + describe: "additional startup script", + type: "string", + }), + handler: Effect.fn("Cli.worktree.create")(function* (args) { + const svc = yield* Worktree.Service + const info = yield* svc.create({ name: args.name, startCommand: args.startCommand }).pipe( + Effect.catch((e) => fail(e.message)), + ) + + UI.println(UI.Style.TEXT_DIM + "Worktree directory: " + UI.Style.TEXT_NORMAL + info.directory) + if (info.branch) { + UI.println(UI.Style.TEXT_DIM + "Branch: " + UI.Style.TEXT_NORMAL + info.branch) + } + UI.empty() + + const result = yield* waitWorktreeEvent(info.directory).pipe( + Effect.timeoutOrElse({ + duration: "120 seconds", + orElse: () => Effect.succeed("timeout" as const), + }), + ) + + if (result === "timeout") { + UI.println(UI.Style.TEXT_WARNING_BOLD + "Worktree created but bootstrap still running" + UI.Style.TEXT_NORMAL) + return + } + + if (result === "failed") { + return yield* fail("Worktree bootstrap failed") + } + + UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Worktree ready" + UI.Style.TEXT_NORMAL) + }), +}) + +export const WorktreeListCommand = effectCmd({ + command: "list", + describe: "list worktrees", + builder: (yargs) => + yargs.option("format", { + describe: "output format", + type: "string", + choices: ["table", "json"], + default: "table", + }), + handler: Effect.fn("Cli.worktree.list")(function* (args) { + const svc = yield* Worktree.Service + const list = yield* svc.list().pipe( + Effect.catch((e) => fail(e.message)), + ) + if (list.length === 0) return + if (args.format === "json") { + console.log(JSON.stringify(list, null, 2)) + } else { + console.log(formatTable(list)) + } + }), +}) + +const resolveWorktreeName = Effect.fnUntraced(function* (svc: Worktree.Interface, name: string) { + const list = yield* svc.list().pipe(Effect.catch((e) => fail(e.message))) + const match = list.find((w) => w.name === name) + if (!match) return name + return match.directory +}) + +export const WorktreeRemoveCommand = effectCmd({ + command: "remove ", + describe: "remove a worktree", + builder: (yargs) => + yargs.positional("name", { + describe: "worktree name or directory", + type: "string", + demandOption: true, + }), + handler: Effect.fn("Cli.worktree.remove")(function* (args) { + const svc = yield* Worktree.Service + const directory = yield* resolveWorktreeName(svc, args.name) + yield* svc.remove({ directory }).pipe( + Effect.catch((e) => fail(e.message)), + ) + UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Worktree removed" + UI.Style.TEXT_NORMAL) + }), +}) + +export const WorktreeResetCommand = effectCmd({ + command: "reset ", + describe: "reset a worktree", + builder: (yargs) => + yargs.positional("name", { + describe: "worktree name or directory", + type: "string", + demandOption: true, + }), + handler: Effect.fn("Cli.worktree.reset")(function* (args) { + const svc = yield* Worktree.Service + const directory = yield* resolveWorktreeName(svc, args.name) + yield* svc.reset({ directory }).pipe( + Effect.catch((e) => fail(e.message)), + ) + UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Worktree reset" + UI.Style.TEXT_NORMAL) + }), +}) diff --git a/packages/opencode/src/control-plane/adapters/worktree.ts b/packages/opencode/src/control-plane/adapters/worktree.ts index 87e30e1139be..62fff2370b03 100644 --- a/packages/opencode/src/control-plane/adapters/worktree.ts +++ b/packages/opencode/src/control-plane/adapters/worktree.ts @@ -26,13 +26,15 @@ const provideContext = (effect: Effect.Effect, context: Worksp ) export const WorktreeAdapter: WorkspaceAdapter = { - name: "Worktree", + name: "Linked workspace", description: "Create a git worktree", async configure(info, context) { const { AppRuntime, Worktree } = await loadWorktree() const next = await AppRuntime.runPromise( provideContext( - Worktree.Service.use((svc) => svc.makeWorktreeInfo({ detached: true })), + Worktree.Service.use((svc) => + svc.makeWorktreeInfo({ detached: false, ...(info.name ? { name: info.name } : {}) }), + ), context, ), ) @@ -40,6 +42,7 @@ export const WorktreeAdapter: WorkspaceAdapter = { ...info, name: next.name, directory: next.directory, + branch: next.branch ?? null, } }, async create(info, _env, _from, context) { diff --git a/packages/opencode/src/control-plane/workspace-adapter-runtime.ts b/packages/opencode/src/control-plane/workspace-adapter-runtime.ts index 235edc9d22b2..00d3e0be9d80 100644 --- a/packages/opencode/src/control-plane/workspace-adapter-runtime.ts +++ b/packages/opencode/src/control-plane/workspace-adapter-runtime.ts @@ -32,7 +32,8 @@ export const create = ( ) => Effect.gen(function* () { const ctx = yield* context - return yield* EffectBridge.fromPromise(() => adapter.create(info, env, from, ctx)) + const workspaceID = ctx.workspaceID ?? info.id + return yield* EffectBridge.fromPromise(() => adapter.create(info, env, from, { ...ctx, workspaceID })) }) export const list = (adapter: WorkspaceAdapter) => diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 8f746e2568a8..02d86ad4ee35 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -64,6 +64,7 @@ export const CreateInput = Schema.Struct({ type: Info.fields.type, branch: Info.fields.branch, projectID: ProjectV2.ID, + name: Schema.optional(Info.fields.name), extra: Schema.optional(Info.fields.extra), }) export type CreateInput = Schema.Schema.Type @@ -72,6 +73,7 @@ export const SessionWarpInput = Schema.Struct({ workspaceID: Schema.NullOr(WorkspaceV2.ID), sessionID: SessionID, copyChanges: Schema.optional(Schema.Boolean), + directory: Schema.optional(Schema.String), }) export type SessionWarpInput = Schema.Schema.Type @@ -439,8 +441,6 @@ const layer = Layer.effect( }) const startSync = Effect.fn("Workspace.startSync")(function* (space: Info) { - if (!flags.experimentalWorkspaces) return - const target = yield* WorkspaceAdapterRuntime.target(space).pipe( Effect.catch((error) => Effect.gen(function* () { @@ -460,6 +460,8 @@ const layer = Layer.effect( return } + if (!flags.experimentalWorkspaces) return + const exists = yield* FiberMap.has(syncFibers, space.id) if (exists && connections.get(space.id)?.status !== "error") return @@ -495,7 +497,7 @@ const layer = Layer.effect( const config = yield* WorkspaceAdapterRuntime.configure(adapter, { ...input, id, - name: Slug.create(), + name: input.name || Slug.create(), directory: null, extra: input.extra ?? null, }) @@ -621,7 +623,7 @@ const layer = Layer.effect( } if (input.workspaceID === null) { - yield* session.setWorkspace({ sessionID: input.sessionID, workspaceID: undefined }) + yield* session.setWorkspace({ sessionID: input.sessionID, workspaceID: undefined, directory: input.directory }) return } diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index ceaff4876036..da1165df91b5 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -25,6 +25,7 @@ import { EOL } from "os" import { WebCommand } from "./cli/cmd/web" import { PrCommand } from "./cli/cmd/pr" import { SessionCommand } from "./cli/cmd/session" +import { WorktreeCommand } from "./cli/cmd/worktree" import { DbCommand } from "./cli/cmd/db" import { errorMessage } from "./util/error" import { PluginCommand } from "./cli/cmd/plug" @@ -80,6 +81,7 @@ const cli = yargs(args) .completion("completion", "generate shell completion script") .command(AcpCommand) .command(McpCommand) + .command(WorktreeCommand) .command(TuiThreadCommand) .command(AttachCommand) .command(RunCommand) diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 9beabf3af744..05d767d7f304 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -322,6 +322,11 @@ export class PatchApplyError extends Schema.TaggedErrorClass()( reason: Schema.Literals(["non-git", "not-clean"]), }) {} +export const StashInput = Schema.Struct({ + message: Schema.String, +}) +export type StashInput = Schema.Schema.Type + export interface Interface { readonly init: () => Effect.Effect readonly branch: () => Effect.Effect @@ -330,6 +335,8 @@ export interface Interface { readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect readonly diffRaw: () => Effect.Effect readonly apply: (input: ApplyInput) => Effect.Effect + readonly stash: (input: StashInput) => Effect.Effect + readonly stashPop: (input: StashInput) => Effect.Effect } interface State { @@ -458,6 +465,24 @@ const layer: Layer.Layer = } return { applied: true } }), + stash: Effect.fn("Vcs.stash")(function* (input: StashInput) { + const ctx = yield* InstanceState.context + if (ctx.project.vcs !== "git") return + yield* git.run(["stash", "push", "-m", input.message], { cwd: ctx.directory }) + }), + stashPop: Effect.fn("Vcs.stashPop")(function* (input: StashInput) { + const ctx = yield* InstanceState.context + if (ctx.project.vcs !== "git") return false + const list = yield* git.run(["stash", "list"], { cwd: ctx.directory }) + const ref = list + .text() + .split(/\r?\n/) + .find((line) => line.includes(input.message)) + ?.split(":")[0] + if (!ref) return false + yield* git.run(["stash", "pop", ref], { cwd: ctx.directory }) + return true + }), }) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts index 6fd18c8624c7..c67317ffe09b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts @@ -48,6 +48,8 @@ export const InstancePaths = { vcsDiff: "/vcs/diff", vcsDiffRaw: "/vcs/diff/raw", vcsApply: "/vcs/apply", + vcsStash: "/vcs/stash", + vcsStashPop: "/vcs/stash-pop", command: "/command", agent: "/agent", skill: "/skill", @@ -136,6 +138,28 @@ export const InstanceApi = HttpApi.make("instance") description: "Apply a raw patch to the current working tree.", }), ), + HttpApiEndpoint.post("vcsStash", InstancePaths.vcsStash, { + query: WorkspaceRoutingQuery, + payload: Vcs.StashInput, + success: described(HttpApiSchema.NoContent, "Changes stashed"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "vcs.stash", + summary: "Stash changes", + description: "Stash uncommitted changes with a message.", + }), + ), + HttpApiEndpoint.post("vcsStashPop", InstancePaths.vcsStashPop, { + query: WorkspaceRoutingQuery, + payload: Vcs.StashInput, + success: described(Schema.Boolean, "Stash popped"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "vcs.stashPop", + summary: "Pop stashed changes", + description: "Pop a stash with a matching message.", + }), + ), HttpApiEndpoint.get("command", InstancePaths.command, { query: WorkspaceRoutingQuery, success: described(Schema.Array(Command.Info), "List of commands"), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts index 09a6e67b6e9b..6198b84896ad 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts @@ -15,6 +15,7 @@ export const WarpPayload = Schema.Struct({ id: Schema.NullOr(Workspace.Info.fields.id), sessionID: Workspace.SessionWarpInput.fields.sessionID, copyChanges: Workspace.SessionWarpInput.fields.copyChanges, + directory: Workspace.SessionWarpInput.fields.directory, }) export class ApiWorkspaceWarpError extends Schema.ErrorClass("WorkspaceWarpError")( diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts index f851b3a31005..3294255986a4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts @@ -73,6 +73,14 @@ export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance" ) }) + const stashVcs = Effect.fn("InstanceHttpApi.vcsStash")(function* (ctx: { payload: Vcs.StashInput }) { + yield* vcs.stash(ctx.payload) + }) + + const stashPopVcs = Effect.fn("InstanceHttpApi.vcsStashPop")(function* (ctx: { payload: Vcs.StashInput }) { + return yield* vcs.stashPop(ctx.payload) + }) + const getCommand = Effect.fn("InstanceHttpApi.command")(function* () { return yield* command.list() }) @@ -101,6 +109,8 @@ export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance" .handle("vcsDiff", getVcsDiff) .handle("vcsDiffRaw", getVcsDiffRaw) .handle("vcsApply", applyVcs) + .handle("vcsStash", stashVcs) + .handle("vcsStashPop", stashPopVcs) .handle("command", getCommand) .handle("agent", getAgent) .handle("skill", getSkill) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts index 7f5c437d9d58..81226d5ace86 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts @@ -62,11 +62,13 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac }) const warp = Effect.fn("WorkspaceHttpApi.warp")(function* (ctx: { payload: typeof WarpPayload.Type }) { + const instance = yield* InstanceState.context yield* workspace .sessionWarp({ workspaceID: ctx.payload.id, sessionID: ctx.payload.sessionID, copyChanges: ctx.payload.copyChanges, + directory: ctx.payload.id === null ? instance.directory : undefined, }) .pipe( Effect.mapError((error) => { diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index de8c3dc4cbd1..9b09c24048f2 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -445,7 +445,11 @@ export interface Interface { readonly clearRevert: (sessionID: SessionID) => Effect.Effect readonly setSummary: (input: { sessionID: SessionID; summary: Info["summary"] }) => Effect.Effect readonly setShare: (input: { sessionID: SessionID; share: Info["share"] }) => Effect.Effect - readonly setWorkspace: (input: { sessionID: SessionID; workspaceID: Info["workspaceID"] }) => Effect.Effect + readonly setWorkspace: (input: { + sessionID: SessionID + workspaceID: Info["workspaceID"] + directory?: string + }) => Effect.Effect readonly diff: (sessionID: SessionID) => Effect.Effect readonly messages: (input: { sessionID: SessionID; limit?: number }) => Effect.Effect readonly children: (parentID: SessionID) => Effect.Effect @@ -816,10 +820,13 @@ const layer: Layer.Layer< const setWorkspace = Effect.fn("Session.setWorkspace")(function* (input: { sessionID: SessionID workspaceID: Info["workspaceID"] + directory?: string }) { - yield* patch(input.sessionID, { workspaceID: input.workspaceID, time: { updated: Date.now() } }).pipe( - Effect.orDie, - ) + yield* patch(input.sessionID, { + workspaceID: input.workspaceID, + ...(input.directory !== undefined ? { directory: input.directory } : {}), + time: { updated: Date.now() }, + }).pipe(Effect.orDie) }) const diff = Effect.fn("Session.diff")(function* (sessionID: SessionID) { diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 9ed0084aac84..344e08a3a8df 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -393,6 +393,10 @@ import type { VcsDiffResponses, VcsGetErrors, VcsGetResponses, + VcsStashErrors, + VcsStashPopErrors, + VcsStashPopResponses, + VcsStashResponses, VcsStatusErrors, VcsStatusResponses, WorktreeCreateErrors, @@ -1050,6 +1054,7 @@ export class Workspace extends HeyApiClient { id?: string type?: string branch?: string | null + name?: string extra?: unknown | null }, options?: Options, @@ -1064,6 +1069,7 @@ export class Workspace extends HeyApiClient { { in: "body", key: "id" }, { in: "body", key: "type" }, { in: "body", key: "branch" }, + { in: "body", key: "name" }, { in: "body", key: "extra" }, ], }, @@ -1196,11 +1202,12 @@ export class Workspace extends HeyApiClient { */ public warp( parameters?: { - directory?: string + query_directory?: string workspace?: string id?: string | null sessionID?: string copyChanges?: boolean + body_directory?: string }, options?: Options, ) { @@ -1209,11 +1216,20 @@ export class Workspace extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, + { + in: "query", + key: "query_directory", + map: "directory", + }, { in: "query", key: "workspace" }, { in: "body", key: "id" }, { in: "body", key: "sessionID" }, { in: "body", key: "copyChanges" }, + { + in: "body", + key: "body_directory", + map: "directory", + }, ], }, ], @@ -2150,6 +2166,80 @@ export class Vcs extends HeyApiClient { }) } + /** + * Stash changes + * + * Stash uncommitted changes with a message. + */ + public stash( + parameters?: { + directory?: string + workspace?: string + message?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "message" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/vcs/stash", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Pop stashed changes + * + * Pop a stash with a matching message. + */ + public stashPop( + parameters?: { + directory?: string + workspace?: string + message?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "message" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/vcs/stash-pop", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + private _diff?: Diff get diff2(): Diff { return (this._diff ??= new Diff({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 5e067f3afb23..bff9a2ee3510 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -8278,6 +8278,66 @@ export type VcsApplyResponses = { export type VcsApplyResponse = VcsApplyResponses[keyof VcsApplyResponses] +export type VcsStashData = { + body?: { + message: string + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/vcs/stash" +} + +export type VcsStashErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type VcsStashError = VcsStashErrors[keyof VcsStashErrors] + +export type VcsStashResponses = { + /** + * Changes stashed + */ + 204: void +} + +export type VcsStashResponse = VcsStashResponses[keyof VcsStashResponses] + +export type VcsStashPopData = { + body?: { + message: string + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/vcs/stash-pop" +} + +export type VcsStashPopErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type VcsStashPopError = VcsStashPopErrors[keyof VcsStashPopErrors] + +export type VcsStashPopResponses = { + /** + * Stash popped + */ + 200: boolean +} + +export type VcsStashPopResponse = VcsStashPopResponses[keyof VcsStashPopResponses] + export type CommandListData = { body?: never path?: never @@ -11063,6 +11123,7 @@ export type ExperimentalWorkspaceCreateData = { id?: string type: string branch?: string | null + name?: string extra?: unknown | null } path?: never @@ -11190,6 +11251,7 @@ export type ExperimentalWorkspaceWarpData = { id: string | null sessionID: string copyChanges?: boolean + directory?: string } path?: never query?: { diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index d370a076ce73..5085eb5f7204 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -48,7 +48,7 @@ import { DialogThemeList } from "./component/dialog-theme-list" import { DialogHelp } from "./ui/dialog-help" import { DialogAgent } from "./component/dialog-agent" import { DialogSessionList } from "./component/dialog-session-list" -import { DialogWorkspaceList } from "./component/dialog-workspace-list" + import { DialogConsoleOrg } from "./component/dialog-console-org" import { ThemeProvider, useTheme } from "./context/theme" import { Home } from "./routes/home" @@ -607,16 +607,6 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi dialog.clear() }, }, - { - name: "workspace.list", - title: "Manage workspaces", - category: "Workspace", - hidden: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, - slashName: "workspaces", - run: () => { - dialog.replace(() => ) - }, - }, ...Array.from({ length: 9 }, (_, i) => ({ name: `session.quick_switch.${i + 1}`, title: `Switch to session in quick slot ${i + 1}`, diff --git a/packages/tui/src/component/dialog-workspace-create.tsx b/packages/tui/src/component/dialog-workspace-create.tsx index 98c71bb00023..9af63eeb22d8 100644 --- a/packages/tui/src/component/dialog-workspace-create.tsx +++ b/packages/tui/src/component/dialog-workspace-create.tsx @@ -1,48 +1,50 @@ import type { ExperimentalWorkspaceAdapterListResponse, Workspace } from "@opencode-ai/sdk/v2" import { useDialog } from "../ui/dialog" import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select" +import { useTheme } from "../context/theme" import { useSync } from "../context/sync" import { useProject } from "../context/project" import { useRoute } from "../context/route" -import { createMemo, createSignal, onMount } from "solid-js" +import { createEffect, createMemo, createSignal, onMount } from "solid-js" import { errorMessage } from "../util/error" import { useSDK } from "../context/sdk" import { useToast } from "../ui/toast" import { DialogAlert } from "../ui/dialog-alert" +import { DialogConfirm } from "../ui/dialog-confirm" +import { DialogPrompt } from "../ui/dialog-prompt" import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes" type Adapter = ExperimentalWorkspaceAdapterListResponse[number] export type WorkspaceSelection = - | { - type: "none" - } - | { - type: "new" - workspaceType: string - workspaceName: string - } - | { - type: "existing" - workspaceID: string - workspaceType: string - workspaceName: string - } + | { type: "none" } + | { type: "new"; workspaceType: string; workspaceName: string; name?: string } + | { type: "existing"; workspaceID: string; workspaceType: string; workspaceName: string } -type WorkspaceSelectValue = WorkspaceSelection | { type: "existing-list" } -type ExistingWorkspaceSelectValue = { workspace: Workspace } +type WorkspaceSelectValue = WorkspaceSelection -export function recentConnectedWorkspaces(input: { - workspaces: readonly WorkspaceInfo[] - status: (workspaceID: string) => string | undefined - limit?: number - omitWorkspaceID?: string -}) { - const allWorkspaces = input.workspaces.filter((workspace) => input.status(workspace.id) === "connected") - const workspaces = allWorkspaces.toSorted((a, b) => Number(b.timeUsed) - Number(a.timeUsed)) - const recent = workspaces.slice(0, input.limit ?? 3) +function relativeTime(ts: number): string { + const diff = Date.now() - ts + const secs = Math.floor(diff / 1000) + if (secs < 60) return "now" + const mins = Math.floor(secs / 60) + if (mins < 60) return `${mins}m` + const hours = Math.floor(mins / 60) + if (hours < 24) return `${hours}h` + const days = Math.floor(hours / 24) + if (days < 30) return `${days}d` + const months = Math.floor(days / 30) + return `${months}mo` +} - return { recent, hasMore: recent.length < workspaces.length } +export function buildDetails(workspace: Workspace): string[] | undefined { + const lines: string[] = [] + if (workspace.directory) lines.push(workspace.directory) + if (workspace.branch) lines.push(`branch: ${workspace.branch}`) + if (workspace.timeUsed && typeof workspace.timeUsed === "number") { + lines.push(`used ${relativeTime(workspace.timeUsed)} ago`) + } + return lines.length ? lines : undefined } export function warpReminderText(dir: string) { @@ -132,6 +134,17 @@ export async function warpWorkspaceSession(input: { input.project.workspace.set(input.workspaceID) + const targetWorkspace = input.workspaceID + ? input.project.workspace.get(input.workspaceID) + : undefined + if (targetWorkspace?.branch) { + input.sync.set("vcs", { branch: targetWorkspace.branch }) + } + + await Promise.all([ + input.project.sync().catch(() => undefined), + input.sdk.client.vcs.get({ workspace: input.workspaceID ?? undefined }).then((x) => input.sync.set("vcs", x.data)).catch(() => undefined), + ]) await input.sync.bootstrap({ fatal: false }).catch(() => undefined) const dir = input.project.instance.directory() || input.sync.path.directory @@ -152,7 +165,7 @@ export async function warpWorkspaceSession(input: { .catch(() => undefined) } - await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()]) + await Promise.all([input.project.workspace.syncKeepCurrent(), input.sync.session.refresh()]) if (input.done) { input.done() @@ -179,6 +192,7 @@ export function DialogWorkspaceSelect(props: { adapters?: Adapter[] onSelect: (selection: WorkspaceSelection) => Promise | void }) { + const { theme } = useTheme() const dialog = useDialog() const project = useProject() const route = useRoute() @@ -186,10 +200,10 @@ export function DialogWorkspaceSelect(props: { const sdk = useSDK() const toast = useToast() const [adapters, setAdapters] = createSignal(props.adapters) - const omittedWorkspaceID = createMemo(() => (route.data.type === "session" ? project.workspace.current() : undefined)) + const [removing, setRemoving] = createSignal() onMount(() => { - dialog.setSize("medium") + dialog.setSize("large") void (async () => { if (adapters()) return const res = await loadWorkspaceAdapters({ sdk, sync, toast }) @@ -198,111 +212,123 @@ export function DialogWorkspaceSelect(props: { })() }) + async function remove(workspace: Workspace) { + if (removing()) return + + const confirmed = await DialogConfirm.show( + dialog, + "Delete workspace", + `Are you sure you want to delete "${workspace.name}"?`, + "delete", + ) + if (confirmed !== true) return + + setRemoving(workspace.id) + const result = await sdk.client.experimental.workspace.remove({ id: workspace.id }).catch((err) => ({ + error: err, + })) + if (result?.error) { + setRemoving(undefined) + toast.show({ + variant: "error", + title: "Failed to delete workspace", + message: errorMessage(result.error), + }) + return + } + + if (project.workspace.current() === workspace.id) { + project.workspace.set(undefined) + await project.sync().catch(() => undefined) + route.navigate({ type: "home" }) + } + await project.workspace.sync() + await project.sync().catch(() => undefined) + setRemoving(undefined) + } + const options = createMemo[]>(() => { const list = adapters() if (!list) return [] - const { recent, hasMore } = recentConnectedWorkspaces({ - workspaces: project.workspace.list(), - status: project.workspace.status, - omitWorkspaceID: omittedWorkspaceID(), - }) + + const workspaces = project + .workspace.list() + .toSorted((a, b) => Number(b.timeUsed) - Number(a.timeUsed)) + return [ - ...list.map((adapter) => ({ - title: adapter.name, - value: { type: "new" as const, workspaceType: adapter.type, workspaceName: adapter.name }, - description: adapter.description, - category: "New workspace", - })), { - title: "None", + title: "Workspace root", value: { type: "none" as const }, - description: "Use the local project", - category: "Choose workspace", + description: "Use project root directory", + category: "Switch to", }, - ...recent.map((workspace: Workspace) => ({ - title: workspace.name, - description: `(${workspace.type})`, + ...workspaces.map((workspace: Workspace) => { + const isCurrent = workspace.id === project.workspace.current() + return { + title: + removing() === workspace.id + ? "Deleting..." + : isCurrent + ? `${workspace.name} (current)` + : workspace.name, value: { type: "existing" as const, workspaceID: workspace.id, workspaceType: workspace.type, workspaceName: workspace.name, }, - category: "Choose workspace", + category: "Workspaces", + footer: workspace.type === "worktree" ? "linked" : workspace.type, + gutter: () => { + const status = project.workspace.status(workspace.id) + return ( + + + + ) + }, + details: buildDetails(workspace), + } + }), + ...list.map((adapter) => ({ + title: adapter.name, + value: { type: "new" as const, workspaceType: adapter.type, workspaceName: adapter.name }, + description: adapter.description, + category: "Create new", })), - ...(hasMore - ? [ - { - title: "View all workspaces", - value: { type: "existing-list" as const }, - description: "Choose from all workspaces", - category: "Choose workspace", - }, - ] - : []), ] }) if (!adapters()) return null return ( - title="Warp" - skipFilter={true} - renderFilter={false} + title="Move session to..." + renderFilter={true} options={options()} - onSelect={(option) => { + onSelect={async (option) => { if (!option.value) return - if (option.value.type === "none") { - void props.onSelect(option.value) - return - } if (option.value.type === "new") { - void props.onSelect(option.value) - return + const name = await DialogPrompt.show(dialog, "Worktree name (optional)", { + placeholder: "leave empty for random name", + }) + if (name === null) return + option.value.name = name || undefined } - if (option.value.type === "existing") { - void props.onSelect(option.value) - return - } - - dialog.replace(() => ( - - )) - }} - /> - ) -} - -function DialogExistingWorkspaceSelect(props: { - omitWorkspaceID?: string - onSelect: (selection: WorkspaceSelection) => Promise | void -}) { - const project = useProject() - - const options = createMemo[]>(() => - project.workspace - .list() - .filter((workspace) => project.workspace.status(workspace.id) === "connected") - .filter((workspace) => workspace.id !== props.omitWorkspaceID) - .map((workspace: Workspace) => ({ - title: workspace.name, - description: `(${workspace.type})`, - value: { workspace }, - })), - ) - - return ( - - title="Existing Workspace" - options={options()} - onSelect={(option) => { - void props.onSelect({ - type: "existing", - workspaceID: option.value.workspace.id, - workspaceType: option.value.workspace.type, - workspaceName: option.value.workspace.name, - }) + void props.onSelect(option.value) }} + actions={[ + { + command: "session.delete", + title: "delete", + disabled: (option) => !option?.value || option.value.type !== "existing", + onTrigger: (option) => { + const val = option.value + if (val?.type !== "existing") return + const ws = project.workspace.list().find((w) => w.id === val.workspaceID) + if (ws) void remove(ws) + }, + }, + ]} /> ) } diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index d4253b28ba62..aa2fbc407e2a 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -14,7 +14,6 @@ import { registerOpencodeSpinner } from "../register-spinner" import path from "path" import { fileURLToPath } from "url" import { useLocal } from "../../context/local" -import { Flag } from "@opencode-ai/core/flag/flag" import { useTheme } from "../../context/theme" import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime" import { useClipboard } from "../../context/clipboard" @@ -537,7 +536,6 @@ export function Prompt(props: PromptProps) { desc: "Change the workspace for the session", name: "workspace.set", category: "Session", - enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, slashName: "warp", run: () => { workspace.open() @@ -1590,11 +1588,12 @@ export function Prompt(props: PromptProps) { {(() => { const item = label() if (item.type === "new") { + const typeName = item.workspaceType === "worktree" ? "linked workspace" : item.workspaceType if (workspace.creating()) - return `Creating ${item.workspaceType}${".".repeat(workspace.creatingDots())}` + return `Creating ${typeName}${".".repeat(workspace.creatingDots())}` return ( <> - Workspace (new {item.workspaceType}) + Workspace (new {typeName}) ) } diff --git a/packages/tui/src/component/prompt/workspace.tsx b/packages/tui/src/component/prompt/workspace.tsx index 57fad0ec3b8e..8425f25da6fa 100644 --- a/packages/tui/src/component/prompt/workspace.tsx +++ b/packages/tui/src/component/prompt/workspace.tsx @@ -6,7 +6,6 @@ import { useSync } from "../../context/sync" import { useToast } from "../../ui/toast" import { errorMessage } from "../../util/error" import { - confirmWorkspaceFileChanges, openWorkspaceSelect, warpWorkspaceSession, type WorkspaceSelection, @@ -28,7 +27,11 @@ export function usePromptWorkspace(sessionID?: string) { setCreating(true) let result try { - result = await sdk.client.experimental.workspace.create({ type: selection.workspaceType, branch: null }) + result = await sdk.client.experimental.workspace.create({ + type: selection.workspaceType, + branch: null, + ...(selection.name ? { name: selection.name } : {}), + }) } catch (err) { setSelection(undefined) setCreating(false) @@ -66,11 +69,28 @@ export function usePromptWorkspace(sessionID?: string) { return } const sourceWorkspaceID = project.workspace.current() - const copyChanges = await confirmWorkspaceFileChanges({ dialog, sdk, sourceWorkspaceID }) - if (copyChanges === undefined) return setSelection(selection) dialog.clear() + const stashMsg = sessionID ? `opencode:${sessionID}` : undefined + let hasStashed = false + if (stashMsg) { + const status = await sdk.client.vcs.status({ workspace: sourceWorkspaceID }).catch(() => undefined) + if (status?.data?.length) { + if (sourceWorkspaceID) { + const stashResult = await sdk.client.vcs.stash({ message: stashMsg, workspace: sourceWorkspaceID }).catch((err) => ({ error: err })) + if (stashResult && "error" in stashResult) { + toast.show({ title: "Warp", message: "Failed to stash changes", variant: "error" }) + return + } + hasStashed = true + toast.show({ title: "Warp", message: "Uncommitted changes stashed", variant: "info" }) + } else { + toast.show({ title: "Warp", message: "You have uncommitted changes in local project", variant: "info" }) + } + } + } + const workspace = selection.type === "none" ? { id: null, name: "local project" } @@ -88,9 +108,19 @@ export function usePromptWorkspace(sessionID?: string) { sourceWorkspaceID, workspaceID: workspace.id, sessionID, - copyChanges, + copyChanges: false, }) - if (warped) showNotice(workspace.name) + if (warped) { + showNotice(workspace.name) + if (hasStashed && stashMsg) { + const popped = await sdk.client.vcs.stashPop({ message: stashMsg, workspace: sourceWorkspaceID }).catch(() => undefined) + if (!popped?.data) { + toast.show({ title: "Warp", message: "Failed to restore stashed changes", variant: "error" }) + } else { + toast.show({ title: "Warp", message: "Uncommitted changes restored", variant: "success" }) + } + } + } } function showNotice(name: string) { @@ -106,6 +136,14 @@ export function usePromptWorkspace(sessionID?: string) { void openWorkspaceSelect({ dialog, sdk, sync, project, toast, onSelect: warp }) } + createEffect(() => { + const selected = selection() + if (selected && selected.type === "existing") { + const exists = project.workspace.list().some((w) => w.id === selected.workspaceID) + if (!exists) setSelection(undefined) + } + }) + createEffect(() => { if (!creating()) { setCreatingDots(3) diff --git a/packages/tui/src/context/directory.ts b/packages/tui/src/context/directory.ts index b107a40b8164..d6c216ff18d3 100644 --- a/packages/tui/src/context/directory.ts +++ b/packages/tui/src/context/directory.ts @@ -10,8 +10,17 @@ export function useDirectory() { const paths = useTuiPaths() return createMemo(() => { const directory = project.instance.path().directory || paths.cwd - const result = abbreviateHome(directory, paths.home) - if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch - return result + + const workspaceID = project.workspace.current() + const workspace = workspaceID + ? project.workspace.list().find((w) => w.id === workspaceID) + : undefined + + const base = workspace + ? workspace.name + : abbreviateHome(project.data.project.mainDir || directory, paths.home) + + if (sync.data.vcs?.branch) return base + ":" + sync.data.vcs.branch + return base }) } diff --git a/packages/tui/src/context/project.tsx b/packages/tui/src/context/project.tsx index d73a17e7ecda..47b7e37e7602 100644 --- a/packages/tui/src/context/project.tsx +++ b/packages/tui/src/context/project.tsx @@ -52,7 +52,7 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex }) } - async function syncWorkspace() { + async function syncWorkspace(keepCurrent?: boolean) { const listed = await sdk.client.experimental.workspace.list().catch(() => undefined) if (!listed?.data) return const status = await sdk.client.experimental.workspace.status().catch(() => undefined) @@ -61,7 +61,7 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex batch(() => { setStore("workspace", "list", reconcile(listed.data)) setStore("workspace", "status", reconcile(next)) - if (!listed.data.some((item) => item.id === store.workspace.current)) { + if (!keepCurrent && !listed.data.some((item) => item.id === store.workspace.current)) { setStore("workspace", "current", undefined) } }) @@ -108,6 +108,7 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex return store.workspace.status }, sync: syncWorkspace, + syncKeepCurrent: () => syncWorkspace(true), }, sync, } diff --git a/packages/tui/src/context/sdk.tsx b/packages/tui/src/context/sdk.tsx index 93180c6e21da..49fbf3ee5bfe 100644 --- a/packages/tui/src/context/sdk.tsx +++ b/packages/tui/src/context/sdk.tsx @@ -1,6 +1,5 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2" import type { GlobalEvent } from "@opencode-ai/sdk/v2" -import { Flag } from "@opencode-ai/core/flag/flag" import { createSimpleContext } from "./helper" import { batch, onCleanup, onMount } from "solid-js" @@ -93,11 +92,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ sseMaxRetryAttempts: 0, }) - if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { - // Start syncing workspaces, it's important to do this after - // we've started listening to events - await sdk.sync.start().catch(() => {}) - } + // Start syncing workspaces, it's important to do this after + // we've started listening to events + await sdk.sync.start().catch(() => {}) for await (const event of events.stream) { if (ctrl.signal.aborted) break @@ -121,11 +118,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ const unsub = await props.events.subscribe(handleEvent) onCleanup(unsub) - if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { - // Start syncing workspaces, it's important to do this after - // we've started listening to events - await sdk.sync.start().catch(() => {}) - } + // Start syncing workspaces, it's important to do this after + // we've started listening to events + await sdk.sync.start().catch(() => {}) } else { startSSE() } diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 0a2d1b8a1e34..4fc7d6a8fe70 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -493,12 +493,14 @@ export const { .catch(() => emptyConsoleState) const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true }) const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true }) + const vcsPromise = sdk.client.vcs.get({ workspace }).then((x) => x.data).catch(() => undefined) await Promise.all([ providersPromise, providerListPromise, capabilitiesPromise, agentsPromise, configPromise, + vcsPromise, projectPromise, ...(args.continue ? [sessionListPromise] : []), ]) @@ -509,6 +511,7 @@ export const { const consoleStateResponse = consoleStatePromise const agentsResponse = agentsPromise.then((x) => x.data ?? []) const configResponse = configPromise.then((x) => x.data!) + const vcsPromiseResolved = vcsPromise const sessionListResponse = args.continue ? sessionListPromise : undefined return Promise.all([ @@ -518,6 +521,7 @@ export const { consoleStateResponse, agentsResponse, configResponse, + vcsPromiseResolved, ...(sessionListResponse ? [sessionListResponse] : []), ]).then((responses) => { const providers = responses[0] @@ -526,7 +530,8 @@ export const { const consoleState = responses[3] const agents = responses[4] const config = responses[5] - const sessions = responses[6] + const vcs = responses[6] + const sessions = responses[7] batch(() => { setStore("provider", reconcile(providers.providers)) @@ -536,6 +541,7 @@ export const { setStore("console_state", reconcile(consoleState)) setStore("agent", reconcile(agents)) setStore("config", reconcile(config)) + setStore("vcs", reconcile(vcs)) if (sessions !== undefined) setStore("session", reconcile(sessions)) }) }) @@ -557,8 +563,7 @@ export const { setStore("session_status", reconcile(x.data ?? {})) }), sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))), - sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))), - project.workspace.sync(), + project.workspace.syncKeepCurrent(), ]).then(() => { setStore("status", "complete") }) diff --git a/packages/tui/src/feature-plugins/home/footer.tsx b/packages/tui/src/feature-plugins/home/footer.tsx index 41bee5da5a49..b6935cf042cf 100644 --- a/packages/tui/src/feature-plugins/home/footer.tsx +++ b/packages/tui/src/feature-plugins/home/footer.tsx @@ -1,27 +1,14 @@ import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { BuiltinTuiPlugin } from "../builtins" import { createMemo, Match, Show, Switch } from "solid-js" -import { abbreviateHome } from "../../runtime" -import { useTuiPaths } from "../../context/runtime" -import { useHomeSessionDestination } from "../../routes/home/session-destination" +import { useDirectory } from "../../context/directory" const id = "internal:home-footer" function Directory(props: { api: TuiPluginApi }) { const theme = () => props.api.theme.current - const destination = useHomeSessionDestination() - const paths = useTuiPaths() - const dir = createMemo(() => { - const selected = destination?.destination() - if (!selected || selected.type === "new") return - const out = abbreviateHome(selected.directory, paths.home) - const branch = - selected.directory === (props.api.state.path.directory || paths.cwd) ? props.api.state.vcs?.branch : undefined - if (branch) return out + ":" + branch - return out - }) - - return {(value) => {value()}} + const dir = useDirectory() + return {dir()} } function Mcp(props: { api: TuiPluginApi }) { diff --git a/packages/tui/src/feature-plugins/sidebar/footer.tsx b/packages/tui/src/feature-plugins/sidebar/footer.tsx index c90281550985..76784ecbab28 100644 --- a/packages/tui/src/feature-plugins/sidebar/footer.tsx +++ b/packages/tui/src/feature-plugins/sidebar/footer.tsx @@ -17,10 +17,9 @@ function View(props: { api: TuiPluginApi; sessionID: string }) { const done = createMemo(() => props.api.kv.get("dismissed_getting_started", false)) const show = createMemo(() => !has() && !done()) const path = createMemo(() => { - const session = props.api.state.session.get(props.sessionID) - const dir = session?.directory || props.api.state.path.directory || paths.cwd + const dir = props.api.state.path.directory || paths.cwd const out = abbreviateHome(dir, paths.home) - const branch = session?.directory === props.api.state.path.directory ? props.api.state.vcs?.branch : undefined + const branch = props.api.state.vcs?.branch const text = branch ? out + ":" + branch : out const list = text.split("/") return { diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index c3bedf98ca15..924fb32e7671 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -309,6 +309,13 @@ export function Session() { if (result.data.workspaceID !== previousWorkspace) { project.workspace.set(result.data.workspaceID) + const targetWorkspace = result.data.workspaceID + ? project.workspace.get(result.data.workspaceID) + : undefined + if (targetWorkspace?.branch) { + sync.set("vcs", { branch: targetWorkspace.branch }) + } + await sdk.client.vcs.get({ workspace: result.data.workspaceID ?? undefined }).then((x) => sync.set("vcs", x.data)).catch(() => undefined) // Sync all the data for this workspace. Note that this // workspace may not exist anymore which is why this is not diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index dab9103d244c..e76819efa7cc 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -665,6 +665,7 @@ export function DialogSelect(props: DialogSelectProps) { > { - test("returns connected workspaces sorted by time used", () => { - const workspaces = [ - { id: "wrk_a", name: "alpha", timeUsed: 700 }, - { id: "wrk_b", name: "beta", timeUsed: 800 }, - { id: "wrk_c", name: "gamma", timeUsed: 400 }, - { id: "wrk_d", name: "delta", timeUsed: 300 }, - { id: "wrk_e", name: "epsilon", timeUsed: 200 }, - ] - const status = { - wrk_a: "connected", - wrk_b: "disconnected", - wrk_c: "error", - wrk_d: "connected", - wrk_e: "connected", - } as const - - const { recent } = recentConnectedWorkspaces({ - workspaces, - status: (workspaceID) => status[workspaceID as keyof typeof status], - }) +describe("buildDetails", () => { + test("includes directory when present", () => { + const workspace = { id: "wrk_a", name: "alpha", directory: "/home/user/proj", type: "worktree", timeUsed: 500, branch: "feat", extra: null, projectID: "proj" } + const details = buildDetails(workspace) + expect(details?.some((l) => l.includes("/home/user/proj"))).toBe(true) + }) - expect(recent.map((workspace) => workspace.id)).toEqual(["wrk_a", "wrk_d", "wrk_e"]) + test("returns undefined when no details", () => { + const workspace = { id: "wrk_a", name: "alpha", type: "local", timeUsed: 0, extra: null, projectID: "proj" } + expect(buildDetails(workspace)).toBeUndefined() }) })