Compare commits

...

4 Commits

Author SHA1 Message Date
James Long b2c0cbf323 Revert "refactor(core): move workspace sql under worktree"
This reverts commit 3c2005a244.
2026-08-12 20:39:55 +00:00
James Long 3c2005a244 refactor(core): move workspace sql under worktree 2026-08-12 20:39:11 +00:00
James Long 2f92b3762a refactor(api): rename filesystem operation to get 2026-08-12 20:20:20 +00:00
James Long 30b977880c feat(tui): replace tab from session picker 2026-08-12 20:10:28 +00:00
26 changed files with 156 additions and 45 deletions
@@ -103,7 +103,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
const base = pickerRoot(cleaned) || root() || start() const base = pickerRoot(cleaned) || root() || start()
if (!base) return { query: value, items: directories.slice(0, 5) } if (!base) return { query: value, items: directories.slice(0, 5) }
const files = await sdk.api.file const files = await sdk.api.file
.find({ .get({
location: { directory: base }, location: { directory: base },
query: pickerFileSearchQuery(base, value, home()), query: pickerFileSearchQuery(base, value, home()),
type: "file", type: "file",
@@ -135,7 +135,7 @@ test("resolves directory autocomplete from the current browser root", async () =
const sdk = { const sdk = {
api: { api: {
file: { file: {
find: (input: { location?: { directory?: string } }) => { get: (input: { location?: { directory?: string } }) => {
directories.push(input.location?.directory ?? "") directories.push(input.location?.directory ?? "")
return Promise.resolve({ data: [] }) return Promise.resolve({ data: [] })
}, },
@@ -157,7 +157,7 @@ test("keeps indexed directory results for servers that support empty search", as
const sdk = { const sdk = {
api: { api: {
file: { file: {
find: () => Promise.resolve({ data: [{ path: "projects/", type: "directory" }] }), get: () => Promise.resolve({ data: [{ path: "projects/", type: "directory" }] }),
list: () => Promise.reject(new Error("listing should not run when search returns results")), list: () => Promise.reject(new Error("listing should not run when search returns results")),
}, },
}, },
@@ -176,7 +176,7 @@ test("lists the default directory when empty search is unsupported", async () =>
const sdk = { const sdk = {
api: { api: {
file: { file: {
find: () => Promise.resolve({ data: [] }), get: () => Promise.resolve({ data: [] }),
list: (input: { location?: { directory?: string } }) => { list: (input: { location?: { directory?: string } }) => {
calls.push(input.location?.directory ?? "") calls.push(input.location?.directory ?? "")
return Promise.resolve({ return Promise.resolve({
@@ -198,7 +198,7 @@ test("matches the default directory listing when typed search is unsupported", a
const sdk = { const sdk = {
api: { api: {
file: { file: {
find: () => Promise.resolve({ data: [] }), get: () => Promise.resolve({ data: [] }),
list: () => list: () =>
Promise.resolve({ Promise.resolve({
data: [ data: [
@@ -375,7 +375,7 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
const query = normalizePickerDrive(input.path) const query = normalizePickerDrive(input.path)
if (!pathInput) { if (!pathInput) {
const results = await args.sdk.api.file const results = await args.sdk.api.file
.find({ location: { directory: input.directory }, query, type: "directory", limit: 50 }) .get({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
.then((result) => result.data.map((entry) => entry.path)) .then((result) => result.data.map((entry) => entry.path))
.catch(() => []) .catch(() => [])
if (!active()) return [] if (!active()) return []
+1 -1
View File
@@ -212,7 +212,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) => const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
serverSDK() serverSDK()
.api.file.find( .api.file.get(
{ {
location: { directory: sdk().directory }, location: { directory: sdk().directory },
query, query,
+2 -2
View File
@@ -1362,11 +1362,11 @@ export type Endpoint16_1Input = {
readonly limit?: number | undefined readonly limit?: number | undefined
} }
export type Endpoint16_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileSystem.Entry> } export type Endpoint16_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileSystem.Entry> }
export type FileFindOperation<E = never> = (input: Endpoint16_1Input) => Effect.Effect<Endpoint16_1Output, E> export type FileGetOperation<E = never> = (input: Endpoint16_1Input) => Effect.Effect<Endpoint16_1Output, E>
export interface FileApi<E = never> { export interface FileApi<E = never> {
readonly list: FileListOperation<E> readonly list: FileListOperation<E>
readonly find: FileFindOperation<E> readonly get: FileGetOperation<E>
} }
export type Endpoint17_0Input = { export type Endpoint17_0Input = {
@@ -1034,12 +1034,12 @@ const Endpoint16_0 = (raw: RawClient["server.fs"]) => (input?: Endpoint16_0Input
const Endpoint16_1 = (raw: RawClient["server.fs"]) => (input: Endpoint16_1Input) => const Endpoint16_1 = (raw: RawClient["server.fs"]) => (input: Endpoint16_1Input) =>
preserveEffect<Endpoint16_1Output>()( preserveEffect<Endpoint16_1Output>()(
raw["fs.find"]({ raw["fs.get"]({
query: { location: input["location"], query: input["query"], type: input["type"], limit: input["limit"] }, query: { location: input["location"], query: input["query"], type: input["type"], limit: input["limit"] },
}).pipe(Effect.mapError(mapClientError)), }).pipe(Effect.mapError(mapClientError)),
) )
const adaptGroup16 = (raw: RawClient["server.fs"]) => ({ list: Endpoint16_0(raw), find: Endpoint16_1(raw) }) const adaptGroup16 = (raw: RawClient["server.fs"]) => ({ list: Endpoint16_0(raw), get: Endpoint16_1(raw) })
const Endpoint17_0 = (raw: RawClient["server.command"]) => (input?: Endpoint17_0Input) => const Endpoint17_0 = (raw: RawClient["server.command"]) => (input?: Endpoint17_0Input) =>
preserveEffect<Endpoint17_0Output>()( preserveEffect<Endpoint17_0Output>()(
@@ -167,8 +167,8 @@ import type {
FileReadOutput, FileReadOutput,
FileListInput, FileListInput,
FileListOutput, FileListOutput,
FileFindInput, FileGetInput,
FileFindOutput, FileGetOutput,
CommandListInput, CommandListInput,
CommandListOutput, CommandListOutput,
SkillListInput, SkillListInput,
@@ -1473,8 +1473,8 @@ export function make(options: ClientOptions) {
}, },
requestOptions, requestOptions,
), ),
find: (input: FileFindInput, requestOptions?: RequestOptions) => get: (input: FileGetInput, requestOptions?: RequestOptions) =>
request<FileFindOutput>( request<FileGetOutput>(
{ {
method: "GET", method: "GET",
path: `/api/fs/find`, path: `/api/fs/find`,
@@ -5335,7 +5335,7 @@ export type FileListOutput = {
data: Array<FileSystemEntry> data: Array<FileSystemEntry>
} }
export type FileFindInput = { export type FileGetInput = {
readonly location?: { readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly query: string readonly query: string
@@ -5362,7 +5362,7 @@ export type FileFindInput = {
}["limit"] }["limit"]
} }
export type FileFindOutput = { export type FileGetOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<FileSystemEntry> data: Array<FileSystemEntry>
} }
+1 -1
View File
@@ -6866,7 +6866,7 @@
"/api/fs/find": { "/api/fs/find": {
"get": { "get": {
"tags": ["filesystem"], "tags": ["filesystem"],
"operationId": "v2.fs.find", "operationId": "v2.fs.get",
"parameters": [ "parameters": [
{ {
"name": "location", "name": "location",
+1 -1
View File
@@ -8534,7 +8534,7 @@
"/api/fs/find": { "/api/fs/find": {
"get": { "get": {
"tags": ["filesystem"], "tags": ["filesystem"],
"operationId": "v2.fs.find", "operationId": "v2.fs.get",
"parameters": [ "parameters": [
{ {
"name": "location", "name": "location",
+2 -2
View File
@@ -47,14 +47,14 @@ export const FileSystemGroup = HttpApiGroup.make("server.fs")
), ),
) )
.add( .add(
HttpApiEndpoint.get("fs.find", "/api/fs/find", { HttpApiEndpoint.get("fs.get", "/api/fs/find", {
query: FindQuery, query: FindQuery,
success: Location.response(Schema.Array(FileSystem.Entry)), success: Location.response(Schema.Array(FileSystem.Entry)),
}) })
.annotateMerge(locationQueryOpenApi) .annotateMerge(locationQueryOpenApi)
.annotateMerge( .annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "v2.fs.find", identifier: "v2.fs.get",
summary: "Find files", summary: "Find files",
description: "Find recursively ranked filesystem entries relative to the requested location.", description: "Find recursively ranked filesystem entries relative to the requested location.",
}), }),
+1 -1
View File
@@ -28,7 +28,7 @@ export const FileSystemHandler = HttpApiBuilder.group(Api, "server.fs", (handler
}), }),
), ),
) )
.handle("fs.find", (ctx) => .handle("fs.get", (ctx) =>
response( response(
Effect.gen(function* () { Effect.gen(function* () {
const fs = yield* FileSystem.Service const fs = yield* FileSystem.Service
@@ -227,8 +227,9 @@ export function DialogSessionList() {
</box> </box>
} }
onMove={() => setToDelete(undefined)} onMove={() => setToDelete(undefined)}
onSelect={(option) => { onSelect={(option, activation) => {
route.navigate({ type: "session", sessionID: option.value }) if (activation.shift) route.navigate({ type: "session", sessionID: option.value })
else sessionTabs.replace(option.value)
dialog.clear() dialog.clear()
}} }}
actions={[ actions={[
@@ -353,7 +353,7 @@ export function Autocomplete(props: {
const result = await ( const result = await (
input.visible === "directory" input.visible === "directory"
? client.api.file.list({ location: requestLocation }) ? client.api.file.list({ location: requestLocation })
: client.api.file.find({ query: base, limit: 20, location: requestLocation }) : client.api.file.get({ query: base, limit: 20, location: requestLocation })
).then( ).then(
(result) => result, (result) => result,
() => undefined, () => undefined,
+1 -1
View File
@@ -241,7 +241,7 @@ export const Definitions = {
"dialog.select.page_down": keybind("pagedown", "Move down one page in dialog"), "dialog.select.page_down": keybind("pagedown", "Move down one page in dialog"),
"dialog.select.home": keybind("home", "Move to first dialog item"), "dialog.select.home": keybind("home", "Move to first dialog item"),
"dialog.select.end": keybind("end", "Move to last dialog item"), "dialog.select.end": keybind("end", "Move to last dialog item"),
"dialog.select.submit": keybind("return", "Submit selected dialog item"), "dialog.select.submit": keybind("return,shift+return,linefeed", "Submit selected dialog item"),
"dialog.prompt.submit": keybind("return", "Submit dialog prompt"), "dialog.prompt.submit": keybind("return", "Submit dialog prompt"),
"dialog.project_copy.generate": keybind("tab", "Generate project copy name"), "dialog.project_copy.generate": keybind("tab", "Generate project copy name"),
"dialog.move_session.new": keybind("ctrl+m", "New project copy"), "dialog.move_session.new": keybind("ctrl+m", "New project copy"),
+1 -1
View File
@@ -218,7 +218,7 @@ export const Definitions = {
"dialog.select.page_down": keybind("pagedown", "Move down one page in dialog"), "dialog.select.page_down": keybind("pagedown", "Move down one page in dialog"),
"dialog.select.home": keybind("home", "Move to first dialog item"), "dialog.select.home": keybind("home", "Move to first dialog item"),
"dialog.select.end": keybind("end", "Move to last dialog item"), "dialog.select.end": keybind("end", "Move to last dialog item"),
"dialog.select.submit": keybind("return", "Submit selected dialog item"), "dialog.select.submit": keybind("return,shift+return,linefeed", "Submit selected dialog item"),
"dialog.prompt.submit": keybind("return", "Submit dialog prompt"), "dialog.prompt.submit": keybind("return", "Submit dialog prompt"),
"dialog.project_copy.generate": keybind("tab", "Generate project copy name"), "dialog.project_copy.generate": keybind("tab", "Generate project copy name"),
"dialog.move_session.new": keybind("ctrl+m", "New project copy"), "dialog.move_session.new": keybind("ctrl+m", "New project copy"),
@@ -35,6 +35,13 @@ export function openSessionTab(tabs: SessionTab[], tab: SessionTab): SessionTab[
return tabs.map((item, position) => (position === index ? { ...item, title: tab.title } : item)) return tabs.map((item, position) => (position === index ? { ...item, title: tab.title } : item))
} }
export function replaceSessionTab(tabs: SessionTab[], current: string | undefined, tab: SessionTab): SessionTab[] {
if (tabs.some((item) => item.sessionID === tab.sessionID)) return tabs
const index = current ? tabs.findIndex((item) => item.sessionID === current) : -1
if (index === -1) return [...tabs, tab]
return tabs.map((item, position) => (position === index ? tab : item))
}
export function closeSessionTab(tabs: SessionTab[], sessionID: string) { export function closeSessionTab(tabs: SessionTab[], sessionID: string) {
const index = tabs.findIndex((tab) => tab.sessionID === sessionID) const index = tabs.findIndex((tab) => tab.sessionID === sessionID)
// Like openSessionTab and moveSessionTab, a no-op returns the same reference so callers can // Like openSessionTab and moveSessionTab, a no-op returns the same reference so callers can
+21 -5
View File
@@ -19,6 +19,7 @@ import {
openSessionTab, openSessionTab,
recordClosedSessionTab, recordClosedSessionTab,
recordSessionTabHistory, recordSessionTabHistory,
replaceSessionTab,
reopenSessionTab, reopenSessionTab,
type ClosedSessionTab, type ClosedSessionTab,
type SessionTab, type SessionTab,
@@ -64,6 +65,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const fallback = empty() const fallback = empty()
const [promptPulses, setPromptPulses] = createSignal<Record<string, number>>({}) const [promptPulses, setPromptPulses] = createSignal<Record<string, number>>({})
let history: SessionTabHistory = { entries: [], index: -1 } let history: SessionTabHistory = { entries: [], index: -1 }
let replacement: string | undefined
// User-closed tabs eligible for reopening; in-memory like history, deleted sessions pruned. // User-closed tabs eligible for reopening; in-memory like history, deleted sessions pruned.
let closedTabs: ClosedSessionTab[] = [] let closedTabs: ClosedSessionTab[] = []
@@ -132,18 +134,22 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
if (!enabled()) return if (!enabled()) return
if (route.data.type !== "session" || route.data.sessionID === "dummy") return if (route.data.type !== "session" || route.data.sessionID === "dummy") return
const sessionID = root(route.data.sessionID) const sessionID = root(route.data.sessionID)
const replaced = replacement
replacement = undefined
history = recordSessionTabHistory(history, sessionID) history = recordSessionTabHistory(history, sessionID)
const fallback = newTab() ? NEW_SESSION_TAB_TITLE : undefined const fallback = !replaced && newTab() ? NEW_SESSION_TAB_TITLE : undefined
const tabs = openSessionTab(state().tabs, { const tab = {
sessionID, sessionID,
title: title(sessionID, state().tabs.find((tab) => tab.sessionID === sessionID)?.title, fallback), title: title(sessionID, state().tabs.find((tab) => tab.sessionID === sessionID)?.title, fallback),
}) }
const tabs = replaced ? replaceSessionTab(state().tabs, replaced, tab) : openSessionTab(state().tabs, tab)
if (tabs === state().tabs && !state().unread[sessionID]) return if (tabs === state().tabs && !state().unread[sessionID]) return
update((draft) => { update((draft) => {
draft.tabs = openSessionTab(draft.tabs, { const tab = {
sessionID, sessionID,
title: title(sessionID, draft.tabs.find((tab) => tab.sessionID === sessionID)?.title, fallback), title: title(sessionID, draft.tabs.find((tab) => tab.sessionID === sessionID)?.title, fallback),
}) }
draft.tabs = replaced ? replaceSessionTab(draft.tabs, replaced, tab) : openSessionTab(draft.tabs, tab)
delete draft.unread[sessionID] delete draft.unread[sessionID]
}) })
}) })
@@ -251,6 +257,16 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
if (!enabled()) return if (!enabled()) return
route.navigate({ type: "session", sessionID: root(sessionID) }) route.navigate({ type: "session", sessionID: root(sessionID) })
}, },
replace(sessionID: string) {
const target = root(sessionID)
if (!enabled()) {
route.navigate({ type: "session", sessionID: target })
return
}
if (target === current()) return
replacement = current()
route.navigate({ type: "session", sessionID: target })
},
add() { add() {
if (!enabled()) return if (!enabled()) return
const sessionID = current() const sessionID = current()
+1 -1
View File
@@ -235,7 +235,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
getDirectory: () => state.location.directory, getDirectory: () => state.location.directory,
findFiles: (query) => findFiles: (query) =>
state.sdk.file state.sdk.file
.find({ .get({
query, query,
type: "file", type: "file",
location: { directory: state.location.directory, workspace: state.location.workspaceID }, location: { directory: state.location.directory, workspace: state.location.workspaceID },
+13 -6
View File
@@ -1,4 +1,11 @@
import { CliRenderEvents, InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core" import {
CliRenderEvents,
InputRenderable,
RGBA,
ScrollBoxRenderable,
TextAttributes,
type KeyEvent,
} from "@opentui/core"
import { Keymap, type KeymapCommand } from "../context/keymap" import { Keymap, type KeymapCommand } from "../context/keymap"
import { useTheme, useThemes } from "../context/theme" import { useTheme, useThemes } from "../context/theme"
import { entries, filter, flatMap, groupBy, pipe } from "remeda" import { entries, filter, flatMap, groupBy, pipe } from "remeda"
@@ -26,7 +33,7 @@ export interface DialogSelectProps<T> {
ref?: (ref: DialogSelectRef<T>) => void ref?: (ref: DialogSelectRef<T>) => void
onMove?: (option: DialogSelectOption<T>) => void onMove?: (option: DialogSelectOption<T>) => void
onFilter?: (query: string) => void onFilter?: (query: string) => void
onSelect?: (option: DialogSelectOption<T>) => void onSelect?: (option: DialogSelectOption<T>, activation: { shift: boolean }) => void
skipFilter?: boolean skipFilter?: boolean
renderFilter?: boolean renderFilter?: boolean
locked?: boolean locked?: boolean
@@ -382,7 +389,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
} }
} }
function submit() { function submit(_input?: string, event?: KeyEvent) {
if (props.locked) return if (props.locked) return
setStore("input", "keyboard") setStore("input", "keyboard")
const index = focusedAction() const index = focusedAction()
@@ -393,7 +400,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const option = selected() const option = selected()
if (!option) return if (!option) return
option.onSelect?.(dialog) option.onSelect?.(dialog)
props.onSelect?.(option) props.onSelect?.(option, { shift: event?.shift === true || event?.name === "linefeed" })
} }
function moveAction(direction: 1 | -1) { function moveAction(direction: 1 | -1) {
@@ -712,10 +719,10 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
setStore("input", "mouse") setStore("input", "mouse")
setFocusedAction(undefined) setFocusedAction(undefined)
}} }}
onMouseUp={() => { onMouseUp={(event) => {
if (props.locked) return if (props.locked) return
option.onSelect?.(dialog) option.onSelect?.(dialog)
props.onSelect?.(option) props.onSelect?.(option, { shift: event.modifiers.shift })
}} }}
onMouseOver={() => { onMouseOver={() => {
if (props.locked) return if (props.locked) return
@@ -1,5 +1,6 @@
/** @jsxImportSource @opentui/solid */ /** @jsxImportSource @opentui/solid */
import { InputRenderable } from "@opentui/core" import { InputRenderable } from "@opentui/core"
import { MouseButtons } from "@opentui/core/testing"
import { testRender } from "@opentui/solid" import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { mkdir } from "node:fs/promises" import { mkdir } from "node:fs/promises"
@@ -109,6 +110,7 @@ async function mountSelect(
]) ])
const selected: string[] = [] const selected: string[] = []
const shifted: boolean[] = []
const moved: string[] = [] const moved: string[] = []
let replaceOptions!: (options: DialogSelectOption<string>[]) => void let replaceOptions!: (options: DialogSelectOption<string>[]) => void
@@ -127,7 +129,10 @@ async function mountSelect(
focusCurrent={focusCurrent} focusCurrent={focusCurrent}
flat={select?.flat} flat={select?.flat}
onMove={(option) => moved.push(option.value)} onMove={(option) => moved.push(option.value)}
onSelect={(option) => selected.push(option.value)} onSelect={(option, activation) => {
selected.push(option.value)
shifted.push(activation.shift)
}}
/> />
)), )),
) )
@@ -155,9 +160,49 @@ async function mountSelect(
app.renderer.start() app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Mutable options")) await app.waitForFrame((frame) => frame.includes("Mutable options"))
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable) await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
return { app, moved, replaceOptions, selected } return { app, moved, replaceOptions, selected, shifted }
} }
test("reports Shift for keyboard selection", async () => {
await using tmp = await tmpdir()
const select = await mountSelect(tmp.path, [{ title: "Alpha", value: "alpha" }])
try {
select.app.mockInput.pressEnter({ shift: true })
expect(select.selected).toEqual(["alpha"])
expect(select.shifted).toEqual([true])
} finally {
select.app.renderer.destroy()
}
})
test("treats a raw linefeed as Shift selection", async () => {
await using tmp = await tmpdir()
const select = await mountSelect(tmp.path, [{ title: "Alpha", value: "alpha" }])
try {
select.app.renderer.stdin.emit("data", Buffer.from("\n"))
await select.app.waitFor(() => select.selected.length === 1)
expect(select.selected).toEqual(["alpha"])
expect(select.shifted).toEqual([true])
} finally {
select.app.renderer.destroy()
}
})
test("reports Shift for mouse selection", async () => {
await using tmp = await tmpdir()
const select = await mountSelect(tmp.path, [{ title: "Alpha", value: "alpha" }])
try {
await select.app.mockMouse.click(15, 11, MouseButtons.LEFT, { modifiers: { shift: true } })
expect(select.selected).toEqual(["alpha"])
expect(select.shifted).toEqual([true])
} finally {
select.app.renderer.destroy()
}
})
test("budgets option content for constrained and full-width large dialogs", () => { test("budgets option content for constrained and full-width large dialogs", () => {
expect(dialogSelectContentWidth(Math.min(dialogWidth("large"), 62 - 2)) - 7).toBe(41) expect(dialogSelectContentWidth(Math.min(dialogWidth("large"), 62 - 2)) - 7).toBe(41)
expect(dialogSelectContentWidth(Math.min(dialogWidth("large"), 100 - 2)) - 7).toBe(69) expect(dialogSelectContentWidth(Math.min(dialogWidth("large"), 100 - 2)) - 7).toBe(69)
@@ -9,6 +9,7 @@ import {
recordClosedSessionTab, recordClosedSessionTab,
recordSessionTabHistory, recordSessionTabHistory,
reopenSessionTab, reopenSessionTab,
replaceSessionTab,
seedSessionTabMotion, seedSessionTabMotion,
sessionTabComplete, sessionTabComplete,
sessionTabOverflowWidth, sessionTabOverflowWidth,
@@ -89,6 +90,18 @@ describe("session tabs", () => {
expect(openSessionTab(tabs, { sessionID: "a", title: "New" })).toBe(tabs) expect(openSessionTab(tabs, { sessionID: "a", title: "New" })).toBe(tabs)
}) })
test("replaces the active tab without duplicating an already-open target", () => {
const tabs = ["a", "b", "c"].map((sessionID) => ({ sessionID }))
expect(replaceSessionTab(tabs, "b", { sessionID: "d" }).map((tab) => tab.sessionID)).toEqual(["a", "d", "c"])
expect(replaceSessionTab(tabs, "b", { sessionID: "c" })).toBe(tabs)
expect(replaceSessionTab(tabs, undefined, { sessionID: "d" }).map((tab) => tab.sessionID)).toEqual([
"a",
"b",
"c",
"d",
])
})
test("selects the right tab then the left tab after closing", () => { test("selects the right tab then the left tab after closing", () => {
expect(closeSessionTab([{ sessionID: "a" }, { sessionID: "b" }, { sessionID: "c" }], "b")).toEqual({ expect(closeSessionTab([{ sessionID: "a" }, { sessionID: "b" }, { sessionID: "c" }], "b")).toEqual({
tabs: [{ sessionID: "a" }, { sessionID: "c" }], tabs: [{ sessionID: "a" }, { sessionID: "c" }],
@@ -289,3 +289,25 @@ test("add opens the new session tab carrying the current session's location", as
await setup.destroy() await setup.destroy()
} }
}) })
test("replace swaps the current tab while selecting an existing tab preserves the tab list", async () => {
const setup = await renderSessionTabs("first")
try {
await wait(() => setup.tabs.current() === "first")
setup.route.navigate({ type: "session", sessionID: "second" })
await wait(() => setup.tabs.current() === "second" && setup.tabs.tabs().length === 2)
setup.tabs.replace("third")
await wait(
() => setup.tabs.current() === "third" && setup.tabs.tabs().map((tab) => tab.sessionID).join() === "first,third",
)
expect(setup.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["first", "third"])
setup.tabs.replace("first")
await wait(() => setup.tabs.current() === "first")
expect(setup.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["first", "third"])
} finally {
await setup.destroy()
}
})
+2 -2
View File
@@ -504,7 +504,7 @@ describe("run interactive runtime", () => {
const catalogs = stubCatalogLists(sdk, { const catalogs = stubCatalogLists(sdk, {
location: { directory: "/session", workspaceID: "work-1" }, location: { directory: "/session", workspaceID: "work-1" },
}) })
const fileFind = spyOn(sdk.file, "find").mockResolvedValue({ const fileGet = spyOn(sdk.file, "get").mockResolvedValue({
location: { location: {
directory: "/session", directory: "/session",
workspaceID: "work-1", workspaceID: "work-1",
@@ -591,6 +591,6 @@ describe("run interactive runtime", () => {
expect(catalogs.reference).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) }) expect(catalogs.reference).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
expect(catalogs.command).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) }) expect(catalogs.command).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
expect(catalogs.skill).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) }) expect(catalogs.skill).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
expect(fileFind).toHaveBeenCalledWith({ query: "index", type: "file", ...query }) expect(fileGet).toHaveBeenCalledWith({ query: "index", type: "file", ...query })
}) })
}) })
+1 -1
View File
@@ -8534,7 +8534,7 @@
"/api/fs/find": { "/api/fs/find": {
"get": { "get": {
"tags": ["filesystem"], "tags": ["filesystem"],
"operationId": "v2.fs.find", "operationId": "v2.fs.get",
"parameters": [ "parameters": [
{ {
"name": "location", "name": "location",
+1 -1
View File
@@ -8534,7 +8534,7 @@
"/api/fs/find": { "/api/fs/find": {
"get": { "get": {
"tags": ["filesystem"], "tags": ["filesystem"],
"operationId": "v2.fs.find", "operationId": "v2.fs.get",
"parameters": [ "parameters": [
{ {
"name": "location", "name": "location",