diff --git a/packages/core/src/control-plane/move-session.ts b/packages/core/src/control-plane/move-session.ts index e227f88df539..2c4b40af7d2b 100644 --- a/packages/core/src/control-plane/move-session.ts +++ b/packages/core/src/control-plane/move-session.ts @@ -1,6 +1,9 @@ export * as MoveSession from "./move-session" import { Context, DateTime, Effect, Layer, Schema } from "effect" +import { Database } from "../database/database" +import { FSUtil } from "../fs-util" +import { ProjectTable } from "../project/sql" import { makeGlobalNode } from "../effect/app-node" import { EventV2 } from "../event" import { Git } from "../git" @@ -22,6 +25,7 @@ export const Input = Schema.Struct({ sessionID: SessionSchema.ID, destination: Destination, moveChanges: Schema.optional(Schema.Boolean), + allowCrossProject: Schema.optional(Schema.Boolean), }).annotate({ identifier: "MoveSession.Input" }) export type Input = typeof Input.Type @@ -73,6 +77,8 @@ const layer = Layer.effect( const events = yield* EventV2.Service const project = yield* ProjectV2.Service const sessions = yield* SessionStore.Service + const fs = yield* FSUtil.Service + const { db } = yield* Database.Service const moveSession = Effect.fn("MoveSession.moveSession")(function* (input: Input) { const current = yield* sessions.get(input.sessionID) @@ -80,13 +86,31 @@ const layer = Layer.effect( const directory = AbsolutePath.make(input.destination.directory) if (current.location.directory === directory) return + // Ensure target directory exists on disk + yield* fs.ensureDir(directory).pipe(Effect.ignore) + const source = yield* project.resolve(current.location.directory) const destination = yield* project.resolve(directory) - if (current.projectID !== destination.id) { + const isCrossProject = current.projectID !== destination.id + if (isCrossProject && input.allowCrossProject === false) { return yield* new DestinationProjectMismatchError({ expected: current.projectID, actual: destination.id }) } - const moveChanges = input.moveChanges && source.directory !== destination.directory + if (isCrossProject) { + yield* db + .insert(ProjectTable) + .values({ + id: destination.id, + worktree: destination.directory, + vcs: destination.vcs?.type, + sandboxes: [], + }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + } + + const moveChanges = !isCrossProject && input.moveChanges && source.directory !== destination.directory const sourceRepository = moveChanges ? yield* git.repo.discover(current.location.directory) : undefined if (moveChanges && !sourceRepository) return yield* new CaptureChangesError({ message: "Source is not a Git repository" }) @@ -107,6 +131,7 @@ const layer = Layer.effect( sessionID: input.sessionID, location: Location.Ref.make({ directory }), subdirectory: RelativePath.make(path.relative(destination.directory, directory).replaceAll("\\", "/")), + projectID: destination.id, timestamp: yield* DateTime.now, }) @@ -144,5 +169,5 @@ const layer = Layer.effect( export const node = makeGlobalNode({ service: Service, layer, - deps: [Git.node, EventV2.node, ProjectV2.node, SessionStore.node], + deps: [Git.node, EventV2.node, ProjectV2.node, SessionStore.node, FSUtil.node, Database.node], }) diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 792067017d14..999270476e2e 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -246,6 +246,7 @@ const layer = Layer.effectDiscard( .set({ directory: event.data.location.directory, path: event.data.subdirectory, + project_id: event.data.projectID ?? sql`${SessionTable.project_id}`, workspace_id: event.data.location.workspaceID ? WorkspaceV2.ID.make(event.data.location.workspaceID) : null, time_updated: DateTime.toEpochMillis(event.data.timestamp), }) diff --git a/packages/core/test/move-session.test.ts b/packages/core/test/move-session.test.ts index 92beb1fa53a0..1fa147128278 100644 --- a/packages/core/test/move-session.test.ts +++ b/packages/core/test/move-session.test.ts @@ -232,4 +232,70 @@ describe("MoveSession", () => { expect(yield* Effect.promise(() => fs.readFile(path.join(source, "untracked.txt"), "utf8"))).toBe("unrelated\n") }), ) + + it.live("moves session across distinct project repositories without transferring git changes", () => + Effect.gen(function* () { + const rootA = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ) + const rootB = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ) + yield* Effect.promise(() => initRepo(rootA.path)) + yield* Effect.promise(async () => { + await $`git init`.cwd(rootB.path).quiet() + await $`git config core.autocrlf false`.cwd(rootB.path).quiet() + await $`git config core.fsmonitor false`.cwd(rootB.path).quiet() + await $`git config commit.gpgsign false`.cwd(rootB.path).quiet() + await $`git config user.email testB@opencode.test`.cwd(rootB.path).quiet() + await $`git config user.name TestB`.cwd(rootB.path).quiet() + await fs.writeFile(path.join(rootB.path, "other.txt"), "other content\n") + await $`git add other.txt`.cwd(rootB.path).quiet() + await $`git commit -m "different root"`.cwd(rootB.path).quiet() + }) + + const source = abs(yield* Effect.promise(() => fs.realpath(rootA.path))) + const destination = abs(yield* Effect.promise(() => fs.realpath(rootB.path))) + + const projectIDA = (yield* Project.Service.use((service) => service.resolve(source))).id + const projectIDB = (yield* Project.Service.use((service) => service.resolve(destination))).id + expect(projectIDA).not.toBe(projectIDB) + + const sessionID = SessionV2.ID.make("ses_cross_project") + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: projectIDA, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: projectIDA, + slug: "cross", + directory: source, + title: "cross project", + version: "test", + time_created: 1, + time_updated: 1, + }) + .run() + .pipe(Effect.orDie) + + yield* MoveSession.Service.use((service) => + service.moveSession({ sessionID, destination: { directory: destination } }), + ) + + const updated = yield* db + .select({ directory: SessionTable.directory, project_id: SessionTable.project_id }) + .from(SessionTable) + .where(eq(SessionTable.id, sessionID)) + .get() + + expect(updated).toEqual({ directory: destination, project_id: projectIDB }) + }), + ) }) diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index 3a559c3e38a4..8c38fcbdd877 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -10,6 +10,7 @@ import { DateTimeUtcFromMillis, NonNegativeInt, RelativePath } from "./schema" import { FileAttachment, Prompt } from "./prompt" import { SessionID } from "./session-id" import { Location } from "./location" +import { ProjectID } from "./project-id" import { SessionMessage } from "./session-message" import { Revert } from "./revert" @@ -80,6 +81,7 @@ export const Moved = Event.define({ ...Base, location: Location.Ref, subdirectory: RelativePath.pipe(optional), + projectID: ProjectID.pipe(optional), }, }) export type Moved = typeof Moved.Type diff --git a/packages/schema/test/session-event.test.ts b/packages/schema/test/session-event.test.ts new file mode 100644 index 000000000000..7c17e684abe7 --- /dev/null +++ b/packages/schema/test/session-event.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { SessionEvent } from "../src/session-event" +import { ProjectID } from "../src/project-id" +import { AbsolutePath } from "../src/schema" + +describe("SessionEvent.Moved schema", () => { + test("encodes and decodes moved event with projectID", () => { + const raw = { + id: "evt_123", + type: "session.next.moved", + data: { + timestamp: 1700000000000, + sessionID: "ses_123", + location: { + directory: AbsolutePath.make("/path/to/new/repo"), + }, + subdirectory: "sub", + projectID: "proj_456", + }, + } + + const decoded = Schema.decodeUnknownSync(SessionEvent.Moved)(raw) + expect(decoded.data.projectID).toBe(ProjectID.make("proj_456")) + expect(decoded.data.location.directory).toBe(AbsolutePath.make("/path/to/new/repo")) + + const encoded = Schema.encodeSync(SessionEvent.Moved)(decoded) + expect(encoded.data.projectID).toBe("proj_456") + }) + + test("encodes and decodes moved event without optional projectID", () => { + const raw = { + id: "evt_123", + type: "session.next.moved", + data: { + timestamp: 1700000000000, + sessionID: "ses_123", + location: { + directory: AbsolutePath.make("/path/to/new/repo"), + }, + }, + } + + const decoded = Schema.decodeUnknownSync(SessionEvent.Moved)(raw) + expect(decoded.data.projectID).toBeUndefined() + + const encoded = Schema.encodeSync(SessionEvent.Moved)(decoded) + expect(encoded.data.projectID).toBeUndefined() + }) +}) diff --git a/packages/tui/src/component/dialog-move-session.tsx b/packages/tui/src/component/dialog-move-session.tsx index 21912b273316..3e9d50550210 100644 --- a/packages/tui/src/component/dialog-move-session.tsx +++ b/packages/tui/src/component/dialog-move-session.tsx @@ -2,7 +2,8 @@ import { useTerminalDimensions } from "@opentui/solid" import { TextAttributes } from "@opentui/core" import { createMemo, createResource, createSignal, onMount, Show } from "solid-js" import path from "path" -import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select" +import fs from "node:fs" +import { DialogSelect, type DialogSelectOption, type DialogSelectRef } from "../ui/dialog-select" import { useDialog } from "../ui/dialog" import { useSDK } from "../context/sdk" import { useTheme } from "../context/theme" @@ -16,12 +17,100 @@ import { useCommandShortcut } from "../keymap" import { useProject } from "../context/project" import { Spinner } from "./spinner" import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes" +import { DialogPrompt } from "../ui/dialog-prompt" import type { ProjectDirectories } from "@opencode-ai/sdk/v2" import { useRoute } from "../context/route" export type MoveSessionSelection = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" } type ProjectDirectory = ProjectDirectories[number] +export function expandHome(input: string, home: string) { + if (input === "~") return home + if (input.startsWith("~/") || input.startsWith("~\\")) { + return path.join(home, input.slice(2)) + } + return path.resolve(input) +} + +export function canonicalDirectory(input: string, home: string): string { + const expanded = expandHome(input, home) + try { + return fs.realpathSync.native(expanded) + } catch { + return path.resolve(expanded) + } +} + +export function autocompleteDirectories(input: string, home: string, limit = 15): string[] { + const trimmed = input.trim() + if (!trimmed) return [] + + const expanded = expandHome(trimmed, home) + let dirToScan = expanded + let partial = "" + + try { + const stat = fs.statSync(expanded) + if (!stat.isDirectory()) { + dirToScan = path.dirname(expanded) + partial = path.basename(expanded).toLowerCase() + } + } catch { + dirToScan = path.dirname(expanded) + partial = path.basename(expanded).toLowerCase() + } + + const results: string[] = [] + try { + const entries = fs.readdirSync(dirToScan, { withFileTypes: true }) + for (const e of entries) { + if (!e.isDirectory()) continue + if (e.name.startsWith(".")) continue + if (!partial || e.name.toLowerCase().startsWith(partial)) { + const full = path.join(dirToScan, e.name) + results.push(canonicalDirectory(full, home)) + } + } + } catch {} + return results.slice(0, limit) +} + +/** + * Merge directories discovered across every opened project into the picker's + * "Other" section. + * + * The dialog's own directory list is scoped to the active project, so nested + * and sibling projects are otherwise unreachable. Candidates are canonicalized, + * de-duplicated case-insensitively, filtered to directories that exist on disk, + * and sorted alphabetically. + */ +export function mergeProjectDirectories(input: { + candidates: readonly string[] + existing: readonly string[] + home: string + limit?: number + exists?: (directory: string) => boolean +}): string[] { + const existing = new Set( + input.existing.map((directory) => path.normalize(expandHome(directory, input.home)).toLowerCase()), + ) + const exists = input.exists ?? ((directory: string) => fs.existsSync(directory)) + const seen = new Set() + const merged: string[] = [] + + for (const candidate of input.candidates) { + if (!candidate || !candidate.trim()) continue + const canonical = canonicalDirectory(candidate, input.home) + const key = path.normalize(canonical).toLowerCase() + if (existing.has(key) || seen.has(key)) continue + if (!exists(canonical)) continue + seen.add(key) + merged.push(canonical) + } + + return merged.sort((a, b) => a.localeCompare(b)).slice(0, input.limit ?? 50) +} + type DialogMoveSessionProps = { projectID: string current?: MoveSessionSelection @@ -46,6 +135,9 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { const [removing, setRemoving] = createSignal(props.initialRemoving) const [replacementCurrent, setReplacementCurrent] = createSignal() const [loadError, setLoadError] = createSignal() + const [filterQuery, setFilterQuery] = createSignal("") + const [highlightedOption, setHighlightedOption] = createSignal>() + let selectRef: DialogSelectRef | undefined const deleteHint = useCommandShortcut("dialog.move_session.delete") onMount(() => dialog.setSize("xlarge")) @@ -109,6 +201,35 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { ) }) + // Known directories for every opened project (`project.list` is global, not + // scoped to the active project), plus the registered `project_directory` + // rows for each. Without this the picker can never reach a nested or sibling + // project — the sync-backed list is scoped to the active project's directory. + const [otherProjectDirectories, { refetch: refetchOtherProjects }] = createResource(async (): Promise => { + const listed = await sdk.client.project.list({}, { throwOnError: true }).catch(() => undefined) + const projects = listed?.data ?? [] + if (projects.length === 0) return [] + + const registered = await Promise.all( + projects.map((project) => + sdk.client.project + .directories({ projectID: project.id }, { throwOnError: true }) + .then((result) => (result.data ?? []).map((item) => item.directory)) + .catch(() => [] as string[]), + ), + ) + + const candidates: string[] = [] + for (const project of projects) { + candidates.push(project.worktree) + for (const sandbox of project.sandboxes) candidates.push(sandbox) + } + for (const group of registered) { + for (const directory of group) candidates.push(directory) + } + return candidates + }) + const options = createMemo[]>(() => { if (showError()) return [] const data = directoryData() @@ -141,46 +262,112 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { })) .filter((item): item is { location: string; root: ProjectDirectory } => item.root !== undefined) - const list = [...roots.map((root) => ({ location: root.directory, root })), ...subdirectories].toSorted((a, b) => { - const root = roots.indexOf(a.root) - roots.indexOf(b.root) - if (root !== 0) return root - if (a.location === a.root.directory) return -1 - if (b.location === b.root.directory) return 1 - return a.location.localeCompare(b.location) + const knownProjectDirectories = roots.map((root) => root.directory) + const otherProjectDirs = mergeProjectDirectories({ + candidates: [ + ...(otherProjectDirectories() ?? []), + ...sync.data.session.map((session) => session.directory).filter(Boolean), + ], + existing: [...knownProjectDirectories, ...subdirectories.map((item) => item.location)], + home: paths.home, }) + + const otherProjects = otherProjectDirs.map((location) => ({ + location, + root: { directory: location } as ProjectDirectory, + other: true, + })) + + const list = [ + ...roots.map((root) => ({ location: root.directory, root, other: false })), + ...subdirectories.map((item) => ({ location: item.location, root: item.root, other: false })), + ...otherProjects, + ] + .filter((item, index, self) => self.findIndex((s) => s.location === item.location) === index) + .toSorted((a, b) => { + // Keep the active project's checkouts before other projects. + if (a.other !== b.other) return a.other ? 1 : -1 + if (a.other) return a.location.localeCompare(b.location) + const root = roots.indexOf(a.root) - roots.indexOf(b.root) + if (root !== 0) return root + if (a.location === a.root.directory) return -1 + if (b.location === b.root.directory) return 1 + return a.location.localeCompare(b.location) + }) const titleWidth = Math.max(1, Math.min(116, dimensions().width - 2) - 12) - return list.map((item) => { - const title = abbreviateHome(item.location, paths.home) - const suffix = - item.location === item.root.directory ? undefined : path.sep + path.relative(item.root.directory, item.location) - const visible = Locale.truncateLeft(title, titleWidth) - const split = suffix ? Math.max(0, visible.length - suffix.length) : visible.length - const deleting = toDelete() === item.location - const isRemoving = removing() === item.location - return { - title, - titleView: isRemoving ? ( - Deleting {item.location} - ) : deleting ? ( - Press {deleteHint()} again to confirm - ) : suffix ? ( - <> - {visible.slice(0, split)} - {visible.slice(split)} - - ) : undefined, - bg: deleting ? theme.error : undefined, + const custom = filterQuery().trim() + const autoDirs = custom ? autocompleteDirectories(custom, paths.home) : [] + const customOptions: DialogSelectOption[] = [] + + if (custom) { + const canonicalCustom = canonicalDirectory(custom, paths.home) + customOptions.push({ + title: abbreviateHome(canonicalCustom, paths.home), value: { - type: "directory", - directory: item.location, - subdirectory: item.location !== item.root.directory, - } as const, - category: item.root.directory === current ? "Current" : "Other", + type: "directory" as const, + directory: canonicalCustom, + subdirectory: false, + }, + category: "Directories", titleWidth, truncateTitle: "left" as const, + }) + + const sortedSubdirs = autoDirs + .filter((dir) => dir !== canonicalCustom && !list.some((item) => item.location === dir)) + .sort((a, b) => a.localeCompare(b)) + + for (const dir of sortedSubdirs) { + const abbrev = abbreviateHome(dir, paths.home) + customOptions.push({ + title: abbrev, + value: { + type: "directory" as const, + directory: dir, + subdirectory: false, + }, + category: "Directories", + titleWidth, + truncateTitle: "left" as const, + }) } - }) + } + + return [ + ...customOptions, + ...list.map((item) => { + const title = abbreviateHome(item.location, paths.home) + const suffix = + item.location === item.root.directory ? undefined : path.sep + path.relative(item.root.directory, item.location) + const visible = Locale.truncateLeft(title, titleWidth) + const split = suffix ? Math.max(0, visible.length - suffix.length) : visible.length + const deleting = toDelete() === item.location + const isRemoving = removing() === item.location + return { + title, + titleView: isRemoving ? ( + Deleting {item.location} + ) : deleting ? ( + Press {deleteHint()} again to confirm + ) : suffix ? ( + <> + {visible.slice(0, split)} + {visible.slice(split)} + + ) : undefined, + bg: deleting ? theme.error : undefined, + value: { + type: "directory", + directory: item.location, + subdirectory: item.location !== item.root.directory, + } as const, + category: item.root.directory === current ? "Current" : item.root.strategy ? "Copies" : "Other", + titleWidth, + truncateTitle: "left" as const, + } + }), + ] }) const current = createMemo(() => { @@ -273,7 +460,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { }) return } - await refetch() + await Promise.all([refetch(), refetchOtherProjects()]) setRemoving(undefined) setWorking(false) if (await removedCurrent(deletingCurrent)) return @@ -298,6 +485,8 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { } renderFilter={!showError()} + skipFilter={Boolean(filterQuery().trim())} + onFilter={setFilterQuery} options={options()} emptyView={ showError() ? ( @@ -309,12 +498,49 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { ) : undefined } + ref={(r) => (selectRef = r)} locked={showError() || directories.loading || loadedProject.loading || Boolean(removing())} current={current()} onSelect={(option) => { if (option.value) props.onSelect(option.value) }} - onMove={() => setToDelete(undefined)} + onMove={(opt) => { + setToDelete(undefined) + setHighlightedOption(opt) + }} + footerHints={[ + { title: "➔", label: "drill down" }, + { title: "⬅", label: "up" }, + ]} + bindings={[ + { + key: "right", + desc: "Drill down into directory", + cmd: () => { + const opt = highlightedOption() ?? options()[0] + if (!opt || !opt.value || opt.value.type !== "directory") return + const target = canonicalDirectory(opt.value.directory, paths.home) + const withSlash = target.endsWith(path.sep) ? target : target + path.sep + selectRef?.setFilter(withSlash) + }, + }, + { + key: "left", + desc: "Go up one directory level", + cmd: () => { + const current = filterQuery().trim() + if (!current) return + const expanded = expandHome(current, paths.home) + const parent = path.dirname(expanded) + if (parent && parent !== expanded) { + const withSlash = parent.endsWith(path.sep) ? parent : parent + path.sep + selectRef?.setFilter(withSlash) + } else { + selectRef?.setFilter("") + } + }, + }, + ]} actions={ showError() ? [] @@ -322,7 +548,27 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { { command: "dialog.move_session.new", title: "new", - onTrigger: () => props.onSelect({ type: "new" }), + onTrigger: () => { + dialog.replace(() => ( + { + const trimmed = enteredPath.trim() + if (trimmed) { + props.onSelect({ + type: "directory", + directory: canonicalDirectory(trimmed, paths.home), + subdirectory: false, + }) + } else { + props.onSelect({ type: "new" }) + } + }} + onCancel={() => reopen()} + /> + )) + }, }, { command: "dialog.move_session.delete", @@ -337,7 +583,10 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { { command: "dialog.move_session.refresh", title: "refresh", - onTrigger: () => void refetch(), + onTrigger: () => { + void refetch() + void refetchOtherProjects() + }, }, ] } diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index c48c751739ce..4882cb62a262 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -545,7 +545,6 @@ export function Prompt(props: PromptProps) { }, { title: "Move session", - desc: "Move to another project dir", name: "session.move", category: "Session", slashName: "move", diff --git a/packages/tui/src/context/directory.ts b/packages/tui/src/context/directory.ts index b107a40b8164..dc7575505664 100644 --- a/packages/tui/src/context/directory.ts +++ b/packages/tui/src/context/directory.ts @@ -3,15 +3,34 @@ import { useProject } from "./project" import { useSync } from "./sync" import { abbreviateHome } from "../runtime" import { useTuiPaths } from "./runtime" +import { useRoute } from "./route" + +export function resolveDisplayDirectory(input: { + sessionDirectory?: string + instanceDirectory?: string + cwd: string + home: string + branch?: string +}) { + const directory = input.sessionDirectory || input.instanceDirectory || input.cwd + const result = abbreviateHome(directory, input.home) + if (input.branch) return result + ":" + input.branch + return result +} export function useDirectory() { const project = useProject() const sync = useSync() const paths = useTuiPaths() + const route = useRoute() 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 session = route.data.type === "session" ? sync.session.get(route.data.sessionID) : undefined + return resolveDisplayDirectory({ + sessionDirectory: session?.directory, + instanceDirectory: project.instance.path().directory, + cwd: paths.cwd, + home: paths.home, + branch: sync.data.vcs?.branch, + }) }) } diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index df2913ede08c..e36777570fa7 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -73,6 +73,7 @@ export interface DialogSelectOption { export type DialogSelectRef = { filter: string + setFilter(text: string): void filtered: DialogSelectOption[] moveTo(value: T): void } @@ -487,6 +488,16 @@ export function DialogSelect(props: DialogSelectProps) { get filter() { return store.filter }, + setFilter(text: string) { + batch(() => { + setStore("filter", text) + if (input && !input.isDestroyed) { + input.setText(text) + input.gotoBufferEnd() + } + props.onFilter?.(text) + }) + }, get filtered() { return filtered() }, diff --git a/packages/tui/test/component/dialog-move-session.test.ts b/packages/tui/test/component/dialog-move-session.test.ts new file mode 100644 index 000000000000..3fee861cc5b9 --- /dev/null +++ b/packages/tui/test/component/dialog-move-session.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, test } from "bun:test" +import path from "node:path" +import os from "node:os" +import fs from "node:fs" +import { + expandHome, + canonicalDirectory, + autocompleteDirectories, + mergeProjectDirectories, +} from "../../src/component/dialog-move-session" + +describe("dialog move session", () => { + const fakeHome = process.platform === "win32" ? "C:\\Users\\tester" : "/home/tester" + + test("expandHome expands bare ~ to home directory", () => { + expect(expandHome("~", fakeHome)).toBe(fakeHome) + }) + + test("expandHome expands ~/path to home-relative path", () => { + const expected = path.join(fakeHome, "workspace", "project") + expect(expandHome("~/workspace/project", fakeHome)).toBe(expected) + if (process.platform === "win32") { + expect(expandHome("~\\workspace\\project", fakeHome)).toBe(expected) + } + }) + + test("expandHome leaves standard absolute paths untouched", () => { + const absPath = process.platform === "win32" ? "C:\\work\\repo" : "/work/repo" + expect(expandHome(absPath, fakeHome)).toBe(path.resolve(absPath)) + }) + + test("canonicalDirectory normalizes real directory casing on disk", () => { + const cwd = process.cwd() + const lower = cwd.toLowerCase() + const canonical = canonicalDirectory(lower, os.homedir()) + expect(canonical.toLowerCase()).toBe(cwd.toLowerCase()) + if (process.platform === "win32") { + // Confirms drive letter is uppercase and directory matches realpath + expect(canonical[0]).toBe(canonical[0]?.toUpperCase()) + } + }) + + test("autocompleteDirectories discovers subdirectories matching path input", () => { + const testDir = path.join(os.tmpdir(), `auto-test-${Date.now()}`) + fs.mkdirSync(path.join(testDir, "alpha"), { recursive: true }) + fs.mkdirSync(path.join(testDir, "beta"), { recursive: true }) + fs.mkdirSync(path.join(testDir, ".hidden"), { recursive: true }) + + try { + const all = autocompleteDirectories(testDir, os.homedir()) + expect(all.length).toBe(2) + expect(all.some((d) => d.includes("alpha"))).toBe(true) + expect(all.some((d) => d.includes("beta"))).toBe(true) + expect(all.some((d) => d.includes(".hidden"))).toBe(false) + + const partial = autocompleteDirectories(path.join(testDir, "al"), os.homedir()) + expect(partial.length).toBe(1) + expect(partial[0]?.includes("alpha")).toBe(true) + + const withSlash = autocompleteDirectories(testDir + path.sep, os.homedir()) + expect(withSlash.length).toBe(2) + // Check alphabetical ordering + const sorted = [...withSlash].sort((a, b) => a.localeCompare(b)) + expect(withSlash).toEqual(sorted) + } finally { + fs.rmSync(testDir, { recursive: true, force: true }) + } + }) +}) + +describe("mergeProjectDirectories", () => { + const home = process.platform === "win32" ? "C:\\Users\\tester" : "/home/tester" + const always = () => true + + test("merges worktrees and sandboxes from every project", () => { + const merged = mergeProjectDirectories({ + candidates: [ + path.join(home, "work"), + path.join(home, "work", "repos", "HomeLab"), + path.join(home, "work", "sandbox"), + ], + existing: [], + home, + exists: always, + }) + + expect(merged).toEqual([ + path.join(home, "work"), + path.join(home, "work", "repos", "HomeLab"), + path.join(home, "work", "sandbox"), + ]) + }) + + test("excludes directories already shown for the active project", () => { + const active = path.join(home, "work") + const merged = mergeProjectDirectories({ + candidates: [active, path.join(home, "other")], + existing: [active], + home, + exists: always, + }) + + expect(merged).toEqual([path.join(home, "other")]) + }) + + test("normalizes forward slashes and backslashes in existing list", () => { + const activeForward = path.join(home, "work").replaceAll("\\", "/") + const candidateBack = path.join(home, "work").replaceAll("/", "\\") + const merged = mergeProjectDirectories({ + candidates: [candidateBack, path.join(home, "other")], + existing: [activeForward], + home, + exists: always, + }) + + expect(merged).toEqual([path.join(home, "other")]) + }) + + test("de-duplicates case-insensitively", () => { + const dir = path.join(home, "work") + const merged = mergeProjectDirectories({ + candidates: [dir, dir.toUpperCase(), dir], + existing: [], + home, + exists: always, + }) + + expect(merged.length).toBe(1) + }) + + test("drops blank and non-existent directories", () => { + const merged = mergeProjectDirectories({ + candidates: ["", " ", path.join(home, "gone")], + existing: [], + home, + exists: (directory) => directory !== path.join(home, "gone"), + }) + + expect(merged).toEqual([]) + }) + + test("sorts alphabetically and honours the limit", () => { + const merged = mergeProjectDirectories({ + candidates: [path.join(home, "zeta"), path.join(home, "alpha"), path.join(home, "mid")], + existing: [], + home, + exists: always, + }) + expect(merged).toEqual([path.join(home, "alpha"), path.join(home, "mid"), path.join(home, "zeta")]) + + const capped = mergeProjectDirectories({ + candidates: [path.join(home, "zeta"), path.join(home, "alpha")], + existing: [], + home, + exists: always, + limit: 1, + }) + expect(capped).toEqual([path.join(home, "alpha")]) + }) +}) diff --git a/packages/tui/test/context/directory.test.ts b/packages/tui/test/context/directory.test.ts new file mode 100644 index 000000000000..039935c530ce --- /dev/null +++ b/packages/tui/test/context/directory.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test" +import path from "node:path" +import { resolveDisplayDirectory } from "../../src/context/directory" + +describe("resolveDisplayDirectory", () => { + const home = process.platform === "win32" ? "C:\\Users\\tester" : "/home/tester" + const defaultDir = path.join(home, "default-repo") + const movedDir = path.join(home, "another-project", "sub") + + test("uses sessionDirectory when active (fixing #43938 stale directory indicator)", () => { + const result = resolveDisplayDirectory({ + sessionDirectory: movedDir, + instanceDirectory: defaultDir, + cwd: defaultDir, + home, + }) + + const expected = "~" + path.sep + path.join("another-project", "sub") + expect(result).toBe(expected) + }) + + test("falls back to instanceDirectory when not inside a session", () => { + const result = resolveDisplayDirectory({ + sessionDirectory: undefined, + instanceDirectory: defaultDir, + cwd: defaultDir, + home, + }) + + const expected = "~" + path.sep + "default-repo" + expect(result).toBe(expected) + }) + + test("appends git branch when available", () => { + const result = resolveDisplayDirectory({ + sessionDirectory: defaultDir, + instanceDirectory: defaultDir, + cwd: defaultDir, + home, + branch: "feature-branch", + }) + + const expected = "~" + path.sep + "default-repo:feature-branch" + expect(result).toBe(expected) + }) +})