Skip to content

Commit 26885e7

Browse files
arvsrnBrendonovich
andauthored
feat(app): align cmd k menu with v2 styles (anomalyco#35152)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
1 parent dba0801 commit 26885e7

4 files changed

Lines changed: 833 additions & 367 deletions

File tree

Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
import { base64Encode } from "@opencode-ai/core/util/encode"
2+
import { getFilename } from "@opencode-ai/core/util/path"
3+
import { useDialog } from "@opencode-ai/ui/context/dialog"
4+
import { useNavigate } from "@solidjs/router"
5+
import { createMemo, onCleanup } from "solid-js"
6+
import { useCommand, type CommandOption } from "@/context/command"
7+
import { useFile } from "@/context/file"
8+
import { useLanguage } from "@/context/language"
9+
import { useLayout } from "@/context/layout"
10+
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
11+
import { useServerSync } from "@/context/server-sync"
12+
import { createSessionTabs } from "@/pages/session/helpers"
13+
import { useSessionLayout } from "@/pages/session/session-layout"
14+
import { decode64 } from "@/utils/base64"
15+
16+
export type CommandPaletteEntry = {
17+
id: string
18+
type: "command" | "file" | "session"
19+
title: string
20+
description?: string
21+
keybind?: string
22+
category: string
23+
option?: CommandOption
24+
path?: string
25+
directory?: string
26+
sessionID?: string
27+
archived?: number
28+
updated?: number
29+
}
30+
31+
const ENTRY_LIMIT = 5
32+
const COMMON_COMMAND_IDS = [
33+
"session.new",
34+
"workspace.new",
35+
"session.previous",
36+
"session.next",
37+
"terminal.toggle",
38+
"review.toggle",
39+
] as const
40+
41+
export function uniqueCommandPaletteEntries(items: CommandPaletteEntry[]) {
42+
const seen = new Set<string>()
43+
return items.filter((item) => {
44+
if (seen.has(item.id)) return false
45+
seen.add(item.id)
46+
return true
47+
})
48+
}
49+
50+
export function createCommandPaletteFileEntry(path: string, category: string): CommandPaletteEntry {
51+
return {
52+
id: "file:" + path,
53+
type: "file",
54+
title: path,
55+
category,
56+
path,
57+
}
58+
}
59+
60+
export function createCommandPaletteFileOpener(onOpenFile?: (path: string) => void) {
61+
const file = useFile()
62+
const layout = useLayout()
63+
const { tabs, view } = useSessionLayout()
64+
65+
return (path: string) => {
66+
const value = file.tab(path)
67+
void tabs().open(value)
68+
void file.load(path)
69+
if (!view().reviewPanel.opened()) view().reviewPanel.open()
70+
layout.fileTree.setTab("all")
71+
onOpenFile?.(path)
72+
tabs().setActive(value)
73+
}
74+
}
75+
76+
export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) {
77+
const command = useCommand()
78+
const language = useLanguage()
79+
const layout = useLayout()
80+
const file = useFile()
81+
const dialog = useDialog()
82+
const navigate = useNavigate()
83+
const serverSDK = useServerSDK()()
84+
const serverSync = useServerSync()
85+
const { params, tabs } = useSessionLayout()
86+
const openFile = createCommandPaletteFileOpener(props.onOpenFile)
87+
const state = { cleanup: undefined as (() => void) | void, committed: false }
88+
const filesOnly = () => props.filesOnly?.() ?? false
89+
90+
const allowedCommands = createMemo(() => {
91+
if (filesOnly()) return []
92+
return command.options.filter(
93+
(option) =>
94+
!option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open",
95+
)
96+
})
97+
const commandEntries = createMemo(() => {
98+
const category = language.t("palette.group.commands")
99+
return allowedCommands().map((option) => createCommandEntry(option, category))
100+
})
101+
const preferredCommandEntries = createMemo(() => {
102+
const all = allowedCommands()
103+
const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index]))
104+
const picked = all.filter((option) => order.has(option.id))
105+
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
106+
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
107+
const category = language.t("palette.group.commands")
108+
return sorted.map((option) => createCommandEntry(option, category))
109+
})
110+
111+
const tabState = createSessionTabs({
112+
tabs,
113+
pathFromTab: file.pathFromTab,
114+
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
115+
})
116+
const recentFileEntries = createMemo(() => {
117+
const all = tabState.openedTabs()
118+
const active = tabState.activeFileTab()
119+
const order = active ? [active, ...all.filter((item) => item !== active)] : all
120+
const seen = new Set<string>()
121+
const category = language.t("palette.group.files")
122+
return order
123+
.map((item) => file.pathFromTab(item))
124+
.filter((path): path is string => {
125+
if (!path || seen.has(path)) return false
126+
seen.add(path)
127+
return true
128+
})
129+
.slice(0, ENTRY_LIMIT)
130+
.map((path) => createCommandPaletteFileEntry(path, category))
131+
})
132+
const rootFileEntries = createMemo(() => {
133+
const category = language.t("palette.group.files")
134+
return file.tree
135+
.children("")
136+
.filter((node) => node.type === "file")
137+
.map((node) => node.path)
138+
.sort((a, b) => a.localeCompare(b))
139+
.slice(0, ENTRY_LIMIT)
140+
.map((path) => createCommandPaletteFileEntry(path, category))
141+
})
142+
143+
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
144+
const project = createMemo(() => {
145+
const directory = projectDirectory()
146+
if (!directory) return undefined
147+
return layout.projects.list().find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
148+
})
149+
const workspaces = createMemo(() => {
150+
const directory = projectDirectory()
151+
const current = project()
152+
if (!current) return directory ? [directory] : []
153+
const dirs = [current.worktree, ...(current.sandboxes ?? [])]
154+
if (directory && !dirs.includes(directory)) return [...dirs, directory]
155+
return dirs
156+
})
157+
const homedir = createMemo(() => serverSync().data.path.home)
158+
const sessions = createSessionEntries({
159+
workspaces,
160+
label: (directory) => {
161+
const current = project()
162+
const kind =
163+
current && directory === current.worktree
164+
? language.t("workspace.type.local")
165+
: language.t("workspace.type.sandbox")
166+
const [store] = serverSync().child(directory, { bootstrap: false })
167+
const home = homedir()
168+
const path = home ? directory.replace(home, "~") : directory
169+
const name = store.vcs?.branch ?? getFilename(directory)
170+
return `${kind} : ${name || path}`
171+
},
172+
load: (directory) => serverSDK.client.session.list({ directory, roots: true }),
173+
untitled: () => language.t("command.session.new"),
174+
category: () => language.t("command.category.session"),
175+
})
176+
177+
const highlight = (item: CommandPaletteEntry | undefined) => {
178+
state.cleanup?.()
179+
state.cleanup = undefined
180+
if (item?.type !== "command") return
181+
state.cleanup = item.option?.onHighlight?.()
182+
}
183+
184+
const select = (item: CommandPaletteEntry | undefined) => {
185+
if (!item) return
186+
state.committed = true
187+
state.cleanup = undefined
188+
dialog.close()
189+
if (item.type === "command") {
190+
item.option?.onSelect?.("palette")
191+
return
192+
}
193+
if (item.type === "session") {
194+
if (!item.directory || !item.sessionID) return
195+
navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
196+
return
197+
}
198+
if (!item.path) return
199+
openFile(item.path)
200+
}
201+
202+
onCleanup(() => {
203+
if (state.committed) return
204+
state.cleanup?.()
205+
})
206+
207+
return {
208+
language,
209+
file,
210+
commandEntries,
211+
preferredCommandEntries,
212+
recentFileEntries,
213+
rootFileEntries,
214+
sessions,
215+
highlight,
216+
select,
217+
close: () => dialog.close(),
218+
}
219+
}
220+
221+
function createCommandEntry(option: CommandOption, category: string): CommandPaletteEntry {
222+
return {
223+
id: "command:" + option.id,
224+
type: "command",
225+
title: option.title,
226+
description: option.description,
227+
keybind: option.keybind,
228+
category,
229+
option,
230+
}
231+
}
232+
233+
function createSessionEntries(props: {
234+
workspaces: () => string[]
235+
label: (directory: string) => string
236+
load: (directory: string) => ReturnType<ServerSDK["client"]["session"]["list"]>
237+
untitled: () => string
238+
category: () => string
239+
}) {
240+
const state: {
241+
token: number
242+
inflight: Promise<CommandPaletteEntry[]> | undefined
243+
cached: CommandPaletteEntry[] | undefined
244+
} = { token: 0, inflight: undefined, cached: undefined }
245+
246+
return (text: string) => {
247+
if (!text.trim()) {
248+
state.token += 1
249+
state.inflight = undefined
250+
state.cached = undefined
251+
return [] as CommandPaletteEntry[]
252+
}
253+
if (state.cached) return state.cached
254+
if (state.inflight) return state.inflight
255+
256+
const current = state.token
257+
const dirs = props.workspaces()
258+
if (dirs.length === 0) return [] as CommandPaletteEntry[]
259+
260+
state.inflight = Promise.all(
261+
dirs.map((directory) => {
262+
const description = props.label(directory)
263+
return props
264+
.load(directory)
265+
.then((result) =>
266+
(result.data ?? [])
267+
.filter((session) => !!session?.id)
268+
.map((session) => ({
269+
id: session.id,
270+
title: session.title ?? props.untitled(),
271+
description,
272+
directory,
273+
archived: session.time?.archived,
274+
updated: session.time?.updated,
275+
})),
276+
)
277+
.catch(() => [] as SessionEntryInput[])
278+
}),
279+
)
280+
.then((results) => {
281+
if (state.token !== current) return [] as CommandPaletteEntry[]
282+
const seen = new Set<string>()
283+
const next = results
284+
.flat()
285+
.filter((item) => {
286+
const key = `${item.directory}:${item.id}`
287+
if (seen.has(key)) return false
288+
seen.add(key)
289+
return true
290+
})
291+
.map((item) => createSessionEntry(item, props.category()))
292+
state.cached = next
293+
return next
294+
})
295+
.catch(() => [] as CommandPaletteEntry[])
296+
.finally(() => {
297+
state.inflight = undefined
298+
})
299+
300+
return state.inflight
301+
}
302+
}
303+
304+
type SessionEntryInput = {
305+
directory: string
306+
id: string
307+
title: string
308+
description: string
309+
archived?: number
310+
updated?: number
311+
}
312+
313+
function createSessionEntry(input: SessionEntryInput, category: string): CommandPaletteEntry {
314+
return {
315+
id: `session:${input.directory}:${input.id}`,
316+
type: "session",
317+
title: input.title,
318+
description: input.description,
319+
category,
320+
directory: input.directory,
321+
sessionID: input.id,
322+
archived: input.archived,
323+
updated: input.updated,
324+
}
325+
}

0 commit comments

Comments
 (0)