chore: merge dev into v2
This commit is contained in:
commit
10d9d87bc6
129 changed files with 3722 additions and 933 deletions
|
|
@ -203,12 +203,15 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||
return promise
|
||||
}
|
||||
|
||||
const search = (query: string, dirs: "true" | "false") =>
|
||||
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
|
||||
sdk()
|
||||
.client.find.files({ query, dirs })
|
||||
.client.find.files({ query, dirs, limit: options?.limit }, { signal: options?.signal })
|
||||
.then(
|
||||
(x) => (x.data ?? []).map(path.normalize),
|
||||
() => [],
|
||||
(error) => {
|
||||
if (options?.signal?.aborted) throw error
|
||||
return []
|
||||
},
|
||||
)
|
||||
|
||||
const stop = sdk().event.listen((e) => {
|
||||
|
|
@ -284,7 +287,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||
setScrollLeft,
|
||||
selectedLines,
|
||||
setSelectedLines,
|
||||
searchFiles: (query: string) => search(query, "false"),
|
||||
searchFiles: (query: string, options?: { limit?: number; signal?: AbortSignal }) =>
|
||||
search(query, "false", options),
|
||||
searchFilesAndDirectories: (query: string) => search(query, "true"),
|
||||
}
|
||||
},
|
||||
|
|
|
|||
82
packages/app/src/context/layout-tabs.test.ts
Normal file
82
packages/app/src/context/layout-tabs.test.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
SESSION_OPEN_FILE_TAB,
|
||||
closeSessionTab,
|
||||
openSessionTab,
|
||||
previewSessionTab,
|
||||
type SessionTabState,
|
||||
} from "./layout-tabs"
|
||||
|
||||
const state = (all: string[], active?: string, preview?: string): SessionTabState => ({
|
||||
tabs: { all, active },
|
||||
preview,
|
||||
})
|
||||
|
||||
describe("previewSessionTab", () => {
|
||||
test("appends the Open File placeholder", () => {
|
||||
expect(previewSessionTab(state(["file://a.ts"], "file://a.ts"), SESSION_OPEN_FILE_TAB)).toEqual(
|
||||
state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB),
|
||||
)
|
||||
})
|
||||
|
||||
test("replaces the current preview in place", () => {
|
||||
expect(
|
||||
previewSessionTab(
|
||||
state(["context", SESSION_OPEN_FILE_TAB, "file://b.ts"], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB),
|
||||
"file://a.ts",
|
||||
),
|
||||
).toEqual(state(["context", "file://a.ts", "file://b.ts"], "file://a.ts", "file://a.ts"))
|
||||
})
|
||||
|
||||
test("activates a durable tab without duplicating it", () => {
|
||||
expect(
|
||||
previewSessionTab(
|
||||
state(["file://a.ts", SESSION_OPEN_FILE_TAB, "file://b.ts"], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB),
|
||||
"file://b.ts",
|
||||
),
|
||||
).toEqual(state(["file://a.ts", "file://b.ts"], "file://b.ts"))
|
||||
})
|
||||
|
||||
test("replaces a restored Open File placeholder", () => {
|
||||
expect(
|
||||
previewSessionTab(state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB), "file://b.ts"),
|
||||
).toEqual(state(["file://a.ts", "file://b.ts"], "file://b.ts", "file://b.ts"))
|
||||
})
|
||||
})
|
||||
|
||||
describe("openSessionTab", () => {
|
||||
test("pins the current preview", () => {
|
||||
expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "file://a.ts")).toEqual(
|
||||
state(["file://a.ts"], "file://a.ts"),
|
||||
)
|
||||
})
|
||||
|
||||
test("replaces a preview with a directly opened file", () => {
|
||||
expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "file://b.ts")).toEqual(
|
||||
state(["file://b.ts"], "file://b.ts"),
|
||||
)
|
||||
})
|
||||
|
||||
test("keeps the preview when switching to Review", () => {
|
||||
expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "review")).toEqual(
|
||||
state(["file://a.ts"], "review", "file://a.ts"),
|
||||
)
|
||||
})
|
||||
|
||||
test("replaces a restored Open File placeholder with a direct open", () => {
|
||||
expect(openSessionTab(state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB), "file://b.ts")).toEqual(
|
||||
state(["file://a.ts", "file://b.ts"], "file://b.ts"),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("closeSessionTab", () => {
|
||||
test("clears preview metadata and selects the left neighbor", () => {
|
||||
expect(
|
||||
closeSessionTab(
|
||||
state(["file://a.ts", "file://b.ts", "file://c.ts"], "file://b.ts", "file://b.ts"),
|
||||
"file://b.ts",
|
||||
),
|
||||
).toEqual(state(["file://a.ts", "file://c.ts"], "file://a.ts"))
|
||||
})
|
||||
})
|
||||
103
packages/app/src/context/layout-tabs.ts
Normal file
103
packages/app/src/context/layout-tabs.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
export const SESSION_OPEN_FILE_TAB = "open-file"
|
||||
|
||||
export type SessionTabs = {
|
||||
active?: string
|
||||
all: string[]
|
||||
}
|
||||
|
||||
export type SessionTabState = {
|
||||
tabs: SessionTabs
|
||||
preview?: string
|
||||
}
|
||||
|
||||
const sessionTabPreview = (current: SessionTabState) =>
|
||||
current.preview ?? (current.tabs.all.includes(SESSION_OPEN_FILE_TAB) ? SESSION_OPEN_FILE_TAB : undefined)
|
||||
|
||||
export function previewSessionTab(current: SessionTabState, tab: string): SessionTabState {
|
||||
const preview = sessionTabPreview(current)
|
||||
const previewIndex = preview ? current.tabs.all.indexOf(preview) : -1
|
||||
const existingIndex = current.tabs.all.indexOf(tab)
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
if (previewIndex === -1 || preview === tab) {
|
||||
return { tabs: { all: current.tabs.all, active: tab }, preview: preview === tab ? tab : undefined }
|
||||
}
|
||||
return {
|
||||
tabs: { all: current.tabs.all.filter((item) => item !== preview), active: tab },
|
||||
}
|
||||
}
|
||||
|
||||
if (previewIndex === -1) {
|
||||
return { tabs: { all: [...current.tabs.all, tab], active: tab }, preview: tab }
|
||||
}
|
||||
|
||||
return {
|
||||
tabs: {
|
||||
all: current.tabs.all.map((item, index) => (index === previewIndex ? tab : item)),
|
||||
active: tab,
|
||||
},
|
||||
preview: tab,
|
||||
}
|
||||
}
|
||||
|
||||
export function openSessionTab(current: SessionTabState, tab: string): SessionTabState {
|
||||
const preview = sessionTabPreview(current)
|
||||
if (tab === "review") {
|
||||
return {
|
||||
tabs: { all: current.tabs.all.filter((item) => item !== tab), active: tab },
|
||||
preview,
|
||||
}
|
||||
}
|
||||
|
||||
if (tab === "context") {
|
||||
return {
|
||||
tabs: { all: [tab, ...current.tabs.all.filter((item) => item !== tab)], active: tab },
|
||||
preview,
|
||||
}
|
||||
}
|
||||
|
||||
const previewIndex = preview ? current.tabs.all.indexOf(preview) : -1
|
||||
const existingIndex = current.tabs.all.indexOf(tab)
|
||||
if (existingIndex !== -1) {
|
||||
if (previewIndex === -1 || preview === tab) {
|
||||
return { tabs: { all: current.tabs.all, active: tab } }
|
||||
}
|
||||
return {
|
||||
tabs: { all: current.tabs.all.filter((item) => item !== preview), active: tab },
|
||||
}
|
||||
}
|
||||
|
||||
if (previewIndex === -1) {
|
||||
return { tabs: { all: [...current.tabs.all, tab], active: tab } }
|
||||
}
|
||||
|
||||
return {
|
||||
tabs: {
|
||||
all: current.tabs.all.map((item, index) => (index === previewIndex ? tab : item)),
|
||||
active: tab,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function closeSessionTab(current: SessionTabState, tab: string): SessionTabState {
|
||||
if (tab === "review") {
|
||||
if (current.tabs.active !== tab) return current
|
||||
return {
|
||||
tabs: { all: current.tabs.all, active: current.tabs.all[0] },
|
||||
preview: current.preview,
|
||||
}
|
||||
}
|
||||
|
||||
const all = current.tabs.all.filter((item) => item !== tab)
|
||||
const preview = current.preview === tab ? undefined : current.preview
|
||||
if (current.tabs.active !== tab) return { tabs: { ...current.tabs, all }, preview }
|
||||
|
||||
const index = current.tabs.all.indexOf(tab)
|
||||
return {
|
||||
tabs: {
|
||||
all,
|
||||
active: current.tabs.all[index - 1] ?? current.tabs.all[index + 1] ?? all[0],
|
||||
},
|
||||
preview,
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/u
|
|||
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
|
||||
import { requireServerKey } from "@/utils/session-route"
|
||||
import { type DraftTab, useTabs } from "./tabs"
|
||||
import { closeSessionTab, openSessionTab, previewSessionTab, type SessionTabs } from "./layout-tabs"
|
||||
|
||||
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
|
||||
|
||||
|
|
@ -55,14 +56,11 @@ export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant {
|
|||
return "gray"
|
||||
}
|
||||
|
||||
type SessionTabs = {
|
||||
active?: string
|
||||
all: string[]
|
||||
}
|
||||
|
||||
type SessionView = {
|
||||
scroll: Record<string, SessionScroll>
|
||||
reviewOpen?: string[]
|
||||
reviewMode?: ReviewChangeMode
|
||||
reviewFile?: string
|
||||
pendingMessage?: string
|
||||
pendingMessageAt?: number
|
||||
}
|
||||
|
|
@ -78,6 +76,7 @@ export type LocalProject = Partial<Project> & { worktree: string; expanded: bool
|
|||
export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
|
||||
|
||||
export type ReviewDiffStyle = "unified" | "split"
|
||||
export type ReviewChangeMode = "git" | "branch" | "turn"
|
||||
export type ReviewPanelSource = "context-button" | "other"
|
||||
|
||||
export type LayoutRoute =
|
||||
|
|
@ -86,14 +85,6 @@ export type LayoutRoute =
|
|||
| { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key }
|
||||
| { type: "session"; sessionId: string; server?: ServerConnection.Key }
|
||||
|
||||
function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs {
|
||||
const all = current?.all ?? []
|
||||
if (tab === "review") return { all: all.filter((x) => x !== "review"), active: tab }
|
||||
if (tab === "context") return { all: [tab, ...all.filter((x) => x !== tab)], active: tab }
|
||||
if (!all.includes(tab)) return { all: [...all, tab], active: tab }
|
||||
return { all, active: tab }
|
||||
}
|
||||
|
||||
const sessionPath = (key: string) => {
|
||||
const dir = SessionStateKey.route(key).split("/")[0]
|
||||
if (!dir) return
|
||||
|
|
@ -307,6 +298,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||
)
|
||||
const [ephemeral, setEphemeral] = createStore({
|
||||
reviewPanelSource: "other" as ReviewPanelSource,
|
||||
sessionTabPreview: {} as Record<string, string | undefined>,
|
||||
})
|
||||
|
||||
const MAX_SESSION_KEYS = 50
|
||||
|
|
@ -365,6 +357,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||
|
||||
scroll.drop(drop)
|
||||
dropSessionState(drop)
|
||||
setEphemeral(
|
||||
"sessionTabPreview",
|
||||
produce((draft) => {
|
||||
for (const key of drop) delete draft[key]
|
||||
}),
|
||||
)
|
||||
|
||||
for (const key of drop) {
|
||||
usage.used.delete(key)
|
||||
|
|
@ -790,6 +788,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||
view(sessionKey: string | Accessor<string>) {
|
||||
const key = createSessionKeyReader(sessionKey, ensureKey)
|
||||
const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
|
||||
const reviewMode = createMemo(() => {
|
||||
const mode = s().reviewMode
|
||||
if (mode === "git" || mode === "branch" || mode === "turn") return mode
|
||||
})
|
||||
const reviewFile = createMemo(() => {
|
||||
const file = s().reviewFile
|
||||
if (typeof file === "string") return file
|
||||
})
|
||||
const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
|
||||
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED)
|
||||
const reviewPanelSource = createMemo(() => (reviewPanelOpened() ? ephemeral.reviewPanelSource : "other"))
|
||||
|
|
@ -861,6 +867,32 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||
},
|
||||
},
|
||||
review: {
|
||||
mode: reviewMode,
|
||||
setMode(mode: ReviewChangeMode) {
|
||||
const session = key()
|
||||
const current = store.sessionView[session]
|
||||
if (!current) {
|
||||
setStore("sessionView", session, { scroll: {}, reviewMode: mode })
|
||||
prune(session)
|
||||
return
|
||||
}
|
||||
if (current.reviewMode === mode) return
|
||||
setStore("sessionView", session, "reviewMode", mode)
|
||||
prune(session)
|
||||
},
|
||||
file: reviewFile,
|
||||
setFile(file: string) {
|
||||
const session = key()
|
||||
const current = store.sessionView[session]
|
||||
if (!current) {
|
||||
setStore("sessionView", session, { scroll: {}, reviewFile: file })
|
||||
prune(session)
|
||||
return
|
||||
}
|
||||
if (current.reviewFile === file) return
|
||||
setStore("sessionView", session, "reviewFile", file)
|
||||
prune(session)
|
||||
},
|
||||
open: createMemo(() => s().reviewOpen ?? []),
|
||||
setOpen(open: string[]) {
|
||||
const session = key()
|
||||
|
|
@ -932,10 +964,17 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||
const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
|
||||
const normalize = (tab: string) => normalizeSessionTab(path(), tab)
|
||||
const normalizeAll = (all: string[]) => normalizeSessionTabList(path(), all)
|
||||
const apply = (session: string, next: ReturnType<typeof openSessionTab>) => {
|
||||
batch(() => {
|
||||
setStore("sessionTabs", session, next.tabs)
|
||||
setEphemeral("sessionTabPreview", session, next.preview)
|
||||
})
|
||||
}
|
||||
return {
|
||||
tabs,
|
||||
active: createMemo(() => tabs().active),
|
||||
all: createMemo(() => tabs().all.filter((tab) => tab !== "review")),
|
||||
preview: createMemo(() => ephemeral.sessionTabPreview[key()]),
|
||||
setActive(tab: string | undefined) {
|
||||
const session = key()
|
||||
const next = tab ? normalize(tab) : tab
|
||||
|
|
@ -948,40 +987,44 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||
setAll(all: string[]) {
|
||||
const session = key()
|
||||
const next = normalizeAll(all).filter((tab) => tab !== "review")
|
||||
if (!store.sessionTabs[session]) {
|
||||
setStore("sessionTabs", session, { all: next, active: undefined })
|
||||
} else {
|
||||
setStore("sessionTabs", session, "all", next)
|
||||
}
|
||||
batch(() => {
|
||||
if (!store.sessionTabs[session]) {
|
||||
setStore("sessionTabs", session, { all: next, active: undefined })
|
||||
} else {
|
||||
setStore("sessionTabs", session, "all", next)
|
||||
}
|
||||
const preview = ephemeral.sessionTabPreview[session]
|
||||
if (preview && !next.includes(preview)) setEphemeral("sessionTabPreview", session, undefined)
|
||||
})
|
||||
},
|
||||
async open(tab: string) {
|
||||
const session = key()
|
||||
const next = nextSessionTabsForOpen(store.sessionTabs[session], normalize(tab))
|
||||
setStore("sessionTabs", session, next)
|
||||
apply(
|
||||
session,
|
||||
openSessionTab(
|
||||
{ tabs: store.sessionTabs[session] ?? { all: [] }, preview: ephemeral.sessionTabPreview[session] },
|
||||
normalize(tab),
|
||||
),
|
||||
)
|
||||
},
|
||||
previewTab(tab: string) {
|
||||
const session = key()
|
||||
apply(
|
||||
session,
|
||||
previewSessionTab(
|
||||
{ tabs: store.sessionTabs[session] ?? { all: [] }, preview: ephemeral.sessionTabPreview[session] },
|
||||
normalize(tab),
|
||||
),
|
||||
)
|
||||
},
|
||||
close(tab: string) {
|
||||
const session = key()
|
||||
const current = store.sessionTabs[session]
|
||||
if (!current) return
|
||||
|
||||
if (tab === "review") {
|
||||
if (current.active !== tab) return
|
||||
setStore("sessionTabs", session, "active", current.all[0])
|
||||
return
|
||||
}
|
||||
|
||||
const all = current.all.filter((x) => x !== tab)
|
||||
if (current.active !== tab) {
|
||||
setStore("sessionTabs", session, "all", all)
|
||||
return
|
||||
}
|
||||
|
||||
const index = current.all.findIndex((f) => f === tab)
|
||||
const next = current.all[index - 1] ?? current.all[index + 1] ?? all[0]
|
||||
batch(() => {
|
||||
setStore("sessionTabs", session, "all", all)
|
||||
setStore("sessionTabs", session, "active", next)
|
||||
})
|
||||
apply(
|
||||
session,
|
||||
closeSessionTab({ tabs: current, preview: ephemeral.sessionTabPreview[session] }, normalize(tab)),
|
||||
)
|
||||
},
|
||||
move(tab: string, to: number) {
|
||||
const session = key()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue