feat(app): align cmd k menu with v2 styles (#35152)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
This commit is contained in:
co-authored by
Brendan Allan
parent
dba08013d1
commit
26885e7118
@@ -0,0 +1,325 @@
|
|||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { useNavigate } from "@solidjs/router"
|
||||||
|
import { createMemo, onCleanup } from "solid-js"
|
||||||
|
import { useCommand, type CommandOption } from "@/context/command"
|
||||||
|
import { useFile } from "@/context/file"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useLayout } from "@/context/layout"
|
||||||
|
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
|
||||||
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
|
import { decode64 } from "@/utils/base64"
|
||||||
|
|
||||||
|
export type CommandPaletteEntry = {
|
||||||
|
id: string
|
||||||
|
type: "command" | "file" | "session"
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
keybind?: string
|
||||||
|
category: string
|
||||||
|
option?: CommandOption
|
||||||
|
path?: string
|
||||||
|
directory?: string
|
||||||
|
sessionID?: string
|
||||||
|
archived?: number
|
||||||
|
updated?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENTRY_LIMIT = 5
|
||||||
|
const COMMON_COMMAND_IDS = [
|
||||||
|
"session.new",
|
||||||
|
"workspace.new",
|
||||||
|
"session.previous",
|
||||||
|
"session.next",
|
||||||
|
"terminal.toggle",
|
||||||
|
"review.toggle",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export function uniqueCommandPaletteEntries(items: CommandPaletteEntry[]) {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
return items.filter((item) => {
|
||||||
|
if (seen.has(item.id)) return false
|
||||||
|
seen.add(item.id)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCommandPaletteFileEntry(path: string, category: string): CommandPaletteEntry {
|
||||||
|
return {
|
||||||
|
id: "file:" + path,
|
||||||
|
type: "file",
|
||||||
|
title: path,
|
||||||
|
category,
|
||||||
|
path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCommandPaletteFileOpener(onOpenFile?: (path: string) => void) {
|
||||||
|
const file = useFile()
|
||||||
|
const layout = useLayout()
|
||||||
|
const { tabs, view } = useSessionLayout()
|
||||||
|
|
||||||
|
return (path: string) => {
|
||||||
|
const value = file.tab(path)
|
||||||
|
void tabs().open(value)
|
||||||
|
void file.load(path)
|
||||||
|
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
||||||
|
layout.fileTree.setTab("all")
|
||||||
|
onOpenFile?.(path)
|
||||||
|
tabs().setActive(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) {
|
||||||
|
const command = useCommand()
|
||||||
|
const language = useLanguage()
|
||||||
|
const layout = useLayout()
|
||||||
|
const file = useFile()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const serverSDK = useServerSDK()()
|
||||||
|
const serverSync = useServerSync()
|
||||||
|
const { params, tabs } = useSessionLayout()
|
||||||
|
const openFile = createCommandPaletteFileOpener(props.onOpenFile)
|
||||||
|
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
||||||
|
const filesOnly = () => props.filesOnly?.() ?? false
|
||||||
|
|
||||||
|
const allowedCommands = createMemo(() => {
|
||||||
|
if (filesOnly()) return []
|
||||||
|
return command.options.filter(
|
||||||
|
(option) =>
|
||||||
|
!option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const commandEntries = createMemo(() => {
|
||||||
|
const category = language.t("palette.group.commands")
|
||||||
|
return allowedCommands().map((option) => createCommandEntry(option, category))
|
||||||
|
})
|
||||||
|
const preferredCommandEntries = createMemo(() => {
|
||||||
|
const all = allowedCommands()
|
||||||
|
const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index]))
|
||||||
|
const picked = all.filter((option) => order.has(option.id))
|
||||||
|
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
|
||||||
|
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
|
||||||
|
const category = language.t("palette.group.commands")
|
||||||
|
return sorted.map((option) => createCommandEntry(option, category))
|
||||||
|
})
|
||||||
|
|
||||||
|
const tabState = createSessionTabs({
|
||||||
|
tabs,
|
||||||
|
pathFromTab: file.pathFromTab,
|
||||||
|
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
||||||
|
})
|
||||||
|
const recentFileEntries = createMemo(() => {
|
||||||
|
const all = tabState.openedTabs()
|
||||||
|
const active = tabState.activeFileTab()
|
||||||
|
const order = active ? [active, ...all.filter((item) => item !== active)] : all
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const category = language.t("palette.group.files")
|
||||||
|
return order
|
||||||
|
.map((item) => file.pathFromTab(item))
|
||||||
|
.filter((path): path is string => {
|
||||||
|
if (!path || seen.has(path)) return false
|
||||||
|
seen.add(path)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
.slice(0, ENTRY_LIMIT)
|
||||||
|
.map((path) => createCommandPaletteFileEntry(path, category))
|
||||||
|
})
|
||||||
|
const rootFileEntries = createMemo(() => {
|
||||||
|
const category = language.t("palette.group.files")
|
||||||
|
return file.tree
|
||||||
|
.children("")
|
||||||
|
.filter((node) => node.type === "file")
|
||||||
|
.map((node) => node.path)
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
.slice(0, ENTRY_LIMIT)
|
||||||
|
.map((path) => createCommandPaletteFileEntry(path, category))
|
||||||
|
})
|
||||||
|
|
||||||
|
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
||||||
|
const project = createMemo(() => {
|
||||||
|
const directory = projectDirectory()
|
||||||
|
if (!directory) return undefined
|
||||||
|
return layout.projects.list().find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
|
||||||
|
})
|
||||||
|
const workspaces = createMemo(() => {
|
||||||
|
const directory = projectDirectory()
|
||||||
|
const current = project()
|
||||||
|
if (!current) return directory ? [directory] : []
|
||||||
|
const dirs = [current.worktree, ...(current.sandboxes ?? [])]
|
||||||
|
if (directory && !dirs.includes(directory)) return [...dirs, directory]
|
||||||
|
return dirs
|
||||||
|
})
|
||||||
|
const homedir = createMemo(() => serverSync().data.path.home)
|
||||||
|
const sessions = createSessionEntries({
|
||||||
|
workspaces,
|
||||||
|
label: (directory) => {
|
||||||
|
const current = project()
|
||||||
|
const kind =
|
||||||
|
current && directory === current.worktree
|
||||||
|
? language.t("workspace.type.local")
|
||||||
|
: language.t("workspace.type.sandbox")
|
||||||
|
const [store] = serverSync().child(directory, { bootstrap: false })
|
||||||
|
const home = homedir()
|
||||||
|
const path = home ? directory.replace(home, "~") : directory
|
||||||
|
const name = store.vcs?.branch ?? getFilename(directory)
|
||||||
|
return `${kind} : ${name || path}`
|
||||||
|
},
|
||||||
|
load: (directory) => serverSDK.client.session.list({ directory, roots: true }),
|
||||||
|
untitled: () => language.t("command.session.new"),
|
||||||
|
category: () => language.t("command.category.session"),
|
||||||
|
})
|
||||||
|
|
||||||
|
const highlight = (item: CommandPaletteEntry | undefined) => {
|
||||||
|
state.cleanup?.()
|
||||||
|
state.cleanup = undefined
|
||||||
|
if (item?.type !== "command") return
|
||||||
|
state.cleanup = item.option?.onHighlight?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
const select = (item: CommandPaletteEntry | undefined) => {
|
||||||
|
if (!item) return
|
||||||
|
state.committed = true
|
||||||
|
state.cleanup = undefined
|
||||||
|
dialog.close()
|
||||||
|
if (item.type === "command") {
|
||||||
|
item.option?.onSelect?.("palette")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (item.type === "session") {
|
||||||
|
if (!item.directory || !item.sessionID) return
|
||||||
|
navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!item.path) return
|
||||||
|
openFile(item.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (state.committed) return
|
||||||
|
state.cleanup?.()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
language,
|
||||||
|
file,
|
||||||
|
commandEntries,
|
||||||
|
preferredCommandEntries,
|
||||||
|
recentFileEntries,
|
||||||
|
rootFileEntries,
|
||||||
|
sessions,
|
||||||
|
highlight,
|
||||||
|
select,
|
||||||
|
close: () => dialog.close(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCommandEntry(option: CommandOption, category: string): CommandPaletteEntry {
|
||||||
|
return {
|
||||||
|
id: "command:" + option.id,
|
||||||
|
type: "command",
|
||||||
|
title: option.title,
|
||||||
|
description: option.description,
|
||||||
|
keybind: option.keybind,
|
||||||
|
category,
|
||||||
|
option,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSessionEntries(props: {
|
||||||
|
workspaces: () => string[]
|
||||||
|
label: (directory: string) => string
|
||||||
|
load: (directory: string) => ReturnType<ServerSDK["client"]["session"]["list"]>
|
||||||
|
untitled: () => string
|
||||||
|
category: () => string
|
||||||
|
}) {
|
||||||
|
const state: {
|
||||||
|
token: number
|
||||||
|
inflight: Promise<CommandPaletteEntry[]> | undefined
|
||||||
|
cached: CommandPaletteEntry[] | undefined
|
||||||
|
} = { token: 0, inflight: undefined, cached: undefined }
|
||||||
|
|
||||||
|
return (text: string) => {
|
||||||
|
if (!text.trim()) {
|
||||||
|
state.token += 1
|
||||||
|
state.inflight = undefined
|
||||||
|
state.cached = undefined
|
||||||
|
return [] as CommandPaletteEntry[]
|
||||||
|
}
|
||||||
|
if (state.cached) return state.cached
|
||||||
|
if (state.inflight) return state.inflight
|
||||||
|
|
||||||
|
const current = state.token
|
||||||
|
const dirs = props.workspaces()
|
||||||
|
if (dirs.length === 0) return [] as CommandPaletteEntry[]
|
||||||
|
|
||||||
|
state.inflight = Promise.all(
|
||||||
|
dirs.map((directory) => {
|
||||||
|
const description = props.label(directory)
|
||||||
|
return props
|
||||||
|
.load(directory)
|
||||||
|
.then((result) =>
|
||||||
|
(result.data ?? [])
|
||||||
|
.filter((session) => !!session?.id)
|
||||||
|
.map((session) => ({
|
||||||
|
id: session.id,
|
||||||
|
title: session.title ?? props.untitled(),
|
||||||
|
description,
|
||||||
|
directory,
|
||||||
|
archived: session.time?.archived,
|
||||||
|
updated: session.time?.updated,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.catch(() => [] as SessionEntryInput[])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.then((results) => {
|
||||||
|
if (state.token !== current) return [] as CommandPaletteEntry[]
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const next = results
|
||||||
|
.flat()
|
||||||
|
.filter((item) => {
|
||||||
|
const key = `${item.directory}:${item.id}`
|
||||||
|
if (seen.has(key)) return false
|
||||||
|
seen.add(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
.map((item) => createSessionEntry(item, props.category()))
|
||||||
|
state.cached = next
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
.catch(() => [] as CommandPaletteEntry[])
|
||||||
|
.finally(() => {
|
||||||
|
state.inflight = undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
return state.inflight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionEntryInput = {
|
||||||
|
directory: string
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
archived?: number
|
||||||
|
updated?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSessionEntry(input: SessionEntryInput, category: string): CommandPaletteEntry {
|
||||||
|
return {
|
||||||
|
id: `session:${input.directory}:${input.id}`,
|
||||||
|
type: "session",
|
||||||
|
title: input.title,
|
||||||
|
description: input.description,
|
||||||
|
category,
|
||||||
|
directory: input.directory,
|
||||||
|
sessionID: input.id,
|
||||||
|
archived: input.archived,
|
||||||
|
updated: input.updated,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
.command-palette-v2 {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Anchor to the top edge of where a centered 480px-tall dialog would sit, so the
|
||||||
|
top stays put while the content-driven height grows and shrinks. */
|
||||||
|
[data-component="dialog-v2"]:has(.command-palette-v2) {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="dialog-v2"]:has(.command-palette-v2) [data-slot="dialog-container"] {
|
||||||
|
width: min(calc(100vw - 24px), 640px);
|
||||||
|
height: auto;
|
||||||
|
min-height: 280px;
|
||||||
|
max-height: min(calc(100vh - 96px), 480px);
|
||||||
|
margin-top: max(48px, calc((100vh - 480px) / 2));
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--v2-background-bg-base);
|
||||||
|
box-shadow: var(--v2-elevation-floating);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-body {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-search {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-search [data-component="text-input-v2"] {
|
||||||
|
width: 100%;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 6px;
|
||||||
|
outline: 0;
|
||||||
|
background: color-mix(in srgb, var(--v2-background-bg-layer-02) 60%, transparent);
|
||||||
|
box-shadow: none;
|
||||||
|
transition:
|
||||||
|
background-color 120ms ease-in-out,
|
||||||
|
box-shadow 120ms ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-search [data-component="text-input-v2"]:where(:hover):not([data-disabled], [data-invalid]),
|
||||||
|
.command-palette-v2-search [data-component="text-input-v2"]:where(:focus-within):not([data-disabled], [data-invalid]) {
|
||||||
|
background: var(--v2-background-bg-layer-02);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-value"] {
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-leading-icon"] {
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-input"] {
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-scroll {
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-results {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 6px 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-group-title {
|
||||||
|
margin: 6px 0;
|
||||||
|
padding: 0 12px;
|
||||||
|
color: var(--v2-text-text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 440;
|
||||||
|
line-height: 16px;
|
||||||
|
letter-spacing: -0.04px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-row {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 36px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--v2-text-text-base);
|
||||||
|
text-align: left;
|
||||||
|
cursor: default;
|
||||||
|
scroll-margin: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-row[data-active] {
|
||||||
|
background: var(--v2-overlay-simple-overlay-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-row:focus-visible {
|
||||||
|
background: var(--v2-overlay-simple-overlay-hover);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-row-main {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-row-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--v2-icon-icon-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-row-text {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-title {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--v2-text-text-base);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 530;
|
||||||
|
line-height: 16px;
|
||||||
|
letter-spacing: -0.04px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-description,
|
||||||
|
.command-palette-v2-meta {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--v2-text-text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 440;
|
||||||
|
line-height: 16px;
|
||||||
|
letter-spacing: -0.04px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-meta {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-file-path {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: baseline;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 16px;
|
||||||
|
letter-spacing: -0.04px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-file-dir {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--v2-text-text-muted);
|
||||||
|
font-weight: 440;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-file-name {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--v2-text-text-base);
|
||||||
|
font-weight: 530;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-state {
|
||||||
|
display: grid;
|
||||||
|
min-height: 120px;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--v2-text-text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 440;
|
||||||
|
line-height: 16px;
|
||||||
|
letter-spacing: -0.04px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.command-palette-v2-row-text {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-description {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||||
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
|
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||||
|
import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2"
|
||||||
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
|
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||||
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
|
import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
|
||||||
|
import { formatKeybindParts } from "@/context/command"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { getRelativeTime } from "@/utils/time"
|
||||||
|
import {
|
||||||
|
createCommandPaletteFileEntry,
|
||||||
|
createCommandPaletteModel,
|
||||||
|
uniqueCommandPaletteEntries,
|
||||||
|
type CommandPaletteEntry,
|
||||||
|
} from "./command-palette"
|
||||||
|
import "./dialog-command-palette-v2.css"
|
||||||
|
|
||||||
|
function groups(entries: CommandPaletteEntry[]) {
|
||||||
|
const map = new Map<string, CommandPaletteEntry[]>()
|
||||||
|
for (const entry of entries) map.set(entry.category, [...(map.get(entry.category) ?? []), entry])
|
||||||
|
return Array.from(map.entries()).map(([category, entries]) => ({ category, entries }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesEntry(entry: CommandPaletteEntry, query: string) {
|
||||||
|
const value = query.toLowerCase()
|
||||||
|
return [entry.title, entry.description, entry.category].some((text) => text?.toLowerCase().includes(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) {
|
||||||
|
const palette = createCommandPaletteModel(props)
|
||||||
|
const [query, setQuery] = createSignal("")
|
||||||
|
const [active, setActive] = createSignal(0)
|
||||||
|
|
||||||
|
const loadItems = async (text: string) => {
|
||||||
|
const q = text.trim()
|
||||||
|
if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
|
||||||
|
|
||||||
|
const [files, nextSessions] = await Promise.all([palette.file.searchFiles(q), Promise.resolve(palette.sessions(q))])
|
||||||
|
const category = palette.language.t("palette.group.files")
|
||||||
|
return [
|
||||||
|
...palette.commandEntries().filter((entry) => matchesEntry(entry, q)),
|
||||||
|
...nextSessions.filter((entry) => matchesEntry(entry, q)),
|
||||||
|
...files.map((path) => createCommandPaletteFileEntry(path, category)),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const [entries] = createResource(query, loadItems, { initialValue: [] as CommandPaletteEntry[] })
|
||||||
|
// Render stale results while a new query loads to avoid flashing "Loading" per keystroke.
|
||||||
|
const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? []))
|
||||||
|
const groupedEntries = createMemo(() => groups(visibleEntries()))
|
||||||
|
const activeEntry = createMemo(() => visibleEntries()[active()])
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
query()
|
||||||
|
visibleEntries()
|
||||||
|
setActive(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
palette.highlight(activeEntry())
|
||||||
|
})
|
||||||
|
|
||||||
|
let resultsRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
|
const move = (delta: -1 | 1) => {
|
||||||
|
const count = visibleEntries().length
|
||||||
|
if (count === 0) return
|
||||||
|
setActive((index) => (index + delta + count) % count)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
resultsRef?.querySelector("[data-active]")?.scrollIntoView({ block: "nearest" })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault()
|
||||||
|
move(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault()
|
||||||
|
move(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault()
|
||||||
|
palette.select(activeEntry())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault()
|
||||||
|
palette.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog class="command-palette-v2" size="large">
|
||||||
|
<DialogBody class="command-palette-v2-body">
|
||||||
|
<div class="command-palette-v2-search">
|
||||||
|
<TextInputV2
|
||||||
|
value={query()}
|
||||||
|
autofocus
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck={false}
|
||||||
|
appearance="large"
|
||||||
|
placeholder={palette.language.t("palette.search.placeholder")}
|
||||||
|
leadingIcon={<Icon name="magnifying-glass" />}
|
||||||
|
onInput={(event) => setQuery(event.currentTarget.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ScrollView class="command-palette-v2-scroll" viewportRef={(el) => (resultsRef = el)}>
|
||||||
|
<div class="command-palette-v2-results" role="listbox">
|
||||||
|
<Show
|
||||||
|
when={visibleEntries().length > 0}
|
||||||
|
fallback={
|
||||||
|
<div class="command-palette-v2-state">
|
||||||
|
{entries.loading ? palette.language.t("common.loading") : palette.language.t("palette.empty")}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<For each={groupedEntries()}>
|
||||||
|
{(group) => (
|
||||||
|
<div class="command-palette-v2-group">
|
||||||
|
<Show when={group.category}>
|
||||||
|
<div class="command-palette-v2-group-title">{group.category}</div>
|
||||||
|
</Show>
|
||||||
|
<For each={group.entries}>
|
||||||
|
{(item) => (
|
||||||
|
<PaletteRow
|
||||||
|
item={item}
|
||||||
|
active={activeEntry()?.id === item.id}
|
||||||
|
language={palette.language}
|
||||||
|
onActive={() => setActive(visibleEntries().findIndex((entry) => entry.id === item.id))}
|
||||||
|
onSelect={() => palette.select(item)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</ScrollView>
|
||||||
|
</DialogBody>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaletteRow(props: {
|
||||||
|
item: CommandPaletteEntry
|
||||||
|
active: boolean
|
||||||
|
language: ReturnType<typeof useLanguage>
|
||||||
|
onActive: () => void
|
||||||
|
onSelect: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="command-palette-v2-row"
|
||||||
|
role="option"
|
||||||
|
aria-selected={props.active}
|
||||||
|
data-active={props.active ? "" : undefined}
|
||||||
|
onMouseMove={(event) => {
|
||||||
|
// Ignore hover from a static cursor when keyboard scrolling moves rows underneath it.
|
||||||
|
if (event.movementX === 0 && event.movementY === 0) return
|
||||||
|
props.onActive()
|
||||||
|
}}
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onClick={props.onSelect}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
fallback={
|
||||||
|
<div class="command-palette-v2-row-main">
|
||||||
|
<FileIcon node={{ path: props.item.path ?? "", type: "file" }} class="command-palette-v2-row-icon size-4" />
|
||||||
|
<div class="command-palette-v2-file-path">
|
||||||
|
<span class="command-palette-v2-file-dir">{getDirectory(props.item.path ?? "")}</span>
|
||||||
|
<span class="command-palette-v2-file-name">{getFilename(props.item.path ?? "")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Match when={props.item.type === "command"}>
|
||||||
|
<div class="command-palette-v2-row-main">
|
||||||
|
<div class="command-palette-v2-row-text">
|
||||||
|
<span class="command-palette-v2-title">{props.item.title}</span>
|
||||||
|
<Show when={props.item.description}>
|
||||||
|
<span class="command-palette-v2-description">{props.item.description}</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={props.item.keybind}>
|
||||||
|
<KeybindV2 keys={formatKeybindParts(props.item.keybind ?? "", props.language.t)} variant="neutral" />
|
||||||
|
</Show>
|
||||||
|
</Match>
|
||||||
|
<Match when={props.item.type === "session"}>
|
||||||
|
<div class="command-palette-v2-row-main">
|
||||||
|
<Icon name="status" class="command-palette-v2-row-icon" />
|
||||||
|
<div class="command-palette-v2-row-text">
|
||||||
|
<span class="command-palette-v2-title" classList={{ "opacity-70": !!props.item.archived }}>
|
||||||
|
{props.item.title}
|
||||||
|
</span>
|
||||||
|
<Show when={props.item.description}>
|
||||||
|
<span class="command-palette-v2-description" classList={{ "opacity-70": !!props.item.archived }}>
|
||||||
|
{props.item.description}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={props.item.updated}>
|
||||||
|
<span class="command-palette-v2-meta">
|
||||||
|
{getRelativeTime(new Date(props.item.updated!).toISOString(), props.language.t)}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,397 +1,58 @@
|
|||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { useNavigate } from "@solidjs/router"
|
import { createMemo, createSignal, lazy, Match, Show, Switch } from "solid-js"
|
||||||
import { createMemo, createSignal, lazy, Match, onCleanup, Show, Switch } from "solid-js"
|
import { formatKeybind } from "@/context/command"
|
||||||
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
|
|
||||||
import { useServerSync } from "@/context/server-sync"
|
|
||||||
import { useLayout } from "@/context/layout"
|
|
||||||
import { useFile } from "@/context/file"
|
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { createSessionTabs } from "@/pages/session/helpers"
|
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { getRelativeTime } from "@/utils/time"
|
import { getRelativeTime } from "@/utils/time"
|
||||||
|
import {
|
||||||
|
createCommandPaletteFileEntry,
|
||||||
|
createCommandPaletteFileOpener,
|
||||||
|
createCommandPaletteModel,
|
||||||
|
uniqueCommandPaletteEntries,
|
||||||
|
type CommandPaletteEntry,
|
||||||
|
} from "./command-palette"
|
||||||
|
|
||||||
const DialogSelectFileV2 = lazy(() =>
|
const DialogSelectFileV2 = lazy(() =>
|
||||||
import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
|
import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
|
||||||
)
|
)
|
||||||
|
const DialogCommandPaletteV2 = lazy(() =>
|
||||||
type EntryType = "command" | "file" | "session"
|
import("./dialog-command-palette-v2").then((module) => ({ default: module.DialogCommandPaletteV2 })),
|
||||||
|
)
|
||||||
type Entry = {
|
|
||||||
id: string
|
|
||||||
type: EntryType
|
|
||||||
title: string
|
|
||||||
description?: string
|
|
||||||
keybind?: string
|
|
||||||
category: string
|
|
||||||
option?: CommandOption
|
|
||||||
path?: string
|
|
||||||
directory?: string
|
|
||||||
sessionID?: string
|
|
||||||
archived?: number
|
|
||||||
updated?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type DialogSelectFileMode = "all" | "files"
|
type DialogSelectFileMode = "all" | "files"
|
||||||
|
|
||||||
const ENTRY_LIMIT = 5
|
|
||||||
const COMMON_COMMAND_IDS = [
|
|
||||||
"session.new",
|
|
||||||
"workspace.new",
|
|
||||||
"session.previous",
|
|
||||||
"session.next",
|
|
||||||
"terminal.toggle",
|
|
||||||
"review.toggle",
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const uniqueEntries = (items: Entry[]) => {
|
|
||||||
const seen = new Set<string>()
|
|
||||||
const out: Entry[] = []
|
|
||||||
for (const item of items) {
|
|
||||||
if (seen.has(item.id)) continue
|
|
||||||
seen.add(item.id)
|
|
||||||
out.push(item)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
const createCommandEntry = (option: CommandOption, category: string): Entry => ({
|
|
||||||
id: "command:" + option.id,
|
|
||||||
type: "command",
|
|
||||||
title: option.title,
|
|
||||||
description: option.description,
|
|
||||||
keybind: option.keybind,
|
|
||||||
category,
|
|
||||||
option,
|
|
||||||
})
|
|
||||||
|
|
||||||
const createFileEntry = (path: string, category: string): Entry => ({
|
|
||||||
id: "file:" + path,
|
|
||||||
type: "file",
|
|
||||||
title: path,
|
|
||||||
category,
|
|
||||||
path,
|
|
||||||
})
|
|
||||||
|
|
||||||
const createSessionEntry = (
|
|
||||||
input: {
|
|
||||||
directory: string
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
archived?: number
|
|
||||||
updated?: number
|
|
||||||
},
|
|
||||||
category: string,
|
|
||||||
): Entry => ({
|
|
||||||
id: `session:${input.directory}:${input.id}`,
|
|
||||||
type: "session",
|
|
||||||
title: input.title,
|
|
||||||
description: input.description,
|
|
||||||
category,
|
|
||||||
directory: input.directory,
|
|
||||||
sessionID: input.id,
|
|
||||||
archived: input.archived,
|
|
||||||
updated: input.updated,
|
|
||||||
})
|
|
||||||
|
|
||||||
function createCommandEntries(props: {
|
|
||||||
filesOnly: () => boolean
|
|
||||||
command: ReturnType<typeof useCommand>
|
|
||||||
language: ReturnType<typeof useLanguage>
|
|
||||||
}) {
|
|
||||||
const allowed = createMemo(() => {
|
|
||||||
if (props.filesOnly()) return []
|
|
||||||
return props.command.options.filter(
|
|
||||||
(option) =>
|
|
||||||
!option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const list = createMemo(() => {
|
|
||||||
const category = props.language.t("palette.group.commands")
|
|
||||||
return allowed().map((option) => createCommandEntry(option, category))
|
|
||||||
})
|
|
||||||
|
|
||||||
const picks = createMemo(() => {
|
|
||||||
const all = allowed()
|
|
||||||
const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index]))
|
|
||||||
const picked = all.filter((option) => order.has(option.id))
|
|
||||||
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
|
|
||||||
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
|
|
||||||
const category = props.language.t("palette.group.commands")
|
|
||||||
return sorted.map((option) => createCommandEntry(option, category))
|
|
||||||
})
|
|
||||||
|
|
||||||
return { allowed, list, picks }
|
|
||||||
}
|
|
||||||
|
|
||||||
function createFileEntries(props: {
|
|
||||||
file: ReturnType<typeof useFile>
|
|
||||||
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
|
|
||||||
language: ReturnType<typeof useLanguage>
|
|
||||||
}) {
|
|
||||||
const tabState = createSessionTabs({
|
|
||||||
tabs: props.tabs,
|
|
||||||
pathFromTab: props.file.pathFromTab,
|
|
||||||
normalizeTab: (tab) => (tab.startsWith("file://") ? props.file.tab(tab) : tab),
|
|
||||||
})
|
|
||||||
const recent = createMemo(() => {
|
|
||||||
const all = tabState.openedTabs()
|
|
||||||
const active = tabState.activeFileTab()
|
|
||||||
const order = active ? [active, ...all.filter((item) => item !== active)] : all
|
|
||||||
const seen = new Set<string>()
|
|
||||||
const category = props.language.t("palette.group.files")
|
|
||||||
const items: Entry[] = []
|
|
||||||
|
|
||||||
for (const item of order) {
|
|
||||||
const path = props.file.pathFromTab(item)
|
|
||||||
if (!path) continue
|
|
||||||
if (seen.has(path)) continue
|
|
||||||
seen.add(path)
|
|
||||||
items.push(createFileEntry(path, category))
|
|
||||||
}
|
|
||||||
|
|
||||||
return items.slice(0, ENTRY_LIMIT)
|
|
||||||
})
|
|
||||||
|
|
||||||
const root = createMemo(() => {
|
|
||||||
const category = props.language.t("palette.group.files")
|
|
||||||
const nodes = props.file.tree.children("")
|
|
||||||
const paths = nodes
|
|
||||||
.filter((node) => node.type === "file")
|
|
||||||
.map((node) => node.path)
|
|
||||||
.sort((a, b) => a.localeCompare(b))
|
|
||||||
return paths.slice(0, ENTRY_LIMIT).map((path) => createFileEntry(path, category))
|
|
||||||
})
|
|
||||||
|
|
||||||
return { recent, root }
|
|
||||||
}
|
|
||||||
|
|
||||||
function createSessionEntries(props: {
|
|
||||||
workspaces: () => string[]
|
|
||||||
label: (directory: string) => string
|
|
||||||
serverSDK: ServerSDK
|
|
||||||
language: ReturnType<typeof useLanguage>
|
|
||||||
}) {
|
|
||||||
const state: {
|
|
||||||
token: number
|
|
||||||
inflight: Promise<Entry[]> | undefined
|
|
||||||
cached: Entry[] | undefined
|
|
||||||
} = {
|
|
||||||
token: 0,
|
|
||||||
inflight: undefined,
|
|
||||||
cached: undefined,
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessions = (text: string) => {
|
|
||||||
const query = text.trim()
|
|
||||||
if (!query) {
|
|
||||||
state.token += 1
|
|
||||||
state.inflight = undefined
|
|
||||||
state.cached = undefined
|
|
||||||
return [] as Entry[]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.cached) return state.cached
|
|
||||||
if (state.inflight) return state.inflight
|
|
||||||
|
|
||||||
const current = state.token
|
|
||||||
const dirs = props.workspaces()
|
|
||||||
if (dirs.length === 0) return [] as Entry[]
|
|
||||||
|
|
||||||
state.inflight = Promise.all(
|
|
||||||
dirs.map((directory) => {
|
|
||||||
const description = props.label(directory)
|
|
||||||
return props.serverSDK.client.session
|
|
||||||
.list({ directory, roots: true })
|
|
||||||
.then((x) =>
|
|
||||||
(x.data ?? [])
|
|
||||||
.filter((s) => !!s?.id)
|
|
||||||
.map((s) => ({
|
|
||||||
id: s.id,
|
|
||||||
title: s.title ?? props.language.t("command.session.new"),
|
|
||||||
description,
|
|
||||||
directory,
|
|
||||||
archived: s.time?.archived,
|
|
||||||
updated: s.time?.updated,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.catch(
|
|
||||||
() =>
|
|
||||||
[] as {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
directory: string
|
|
||||||
archived?: number
|
|
||||||
updated?: number
|
|
||||||
}[],
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.then((results) => {
|
|
||||||
if (state.token !== current) return [] as Entry[]
|
|
||||||
const seen = new Set<string>()
|
|
||||||
const category = props.language.t("command.category.session")
|
|
||||||
const next = results
|
|
||||||
.flat()
|
|
||||||
.filter((item) => {
|
|
||||||
const key = `${item.directory}:${item.id}`
|
|
||||||
if (seen.has(key)) return false
|
|
||||||
seen.add(key)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
.map((item) => createSessionEntry(item, category))
|
|
||||||
state.cached = next
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
.catch(() => [] as Entry[])
|
|
||||||
.finally(() => {
|
|
||||||
state.inflight = undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
return state.inflight
|
|
||||||
}
|
|
||||||
|
|
||||||
return { sessions }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
|
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
|
||||||
const command = useCommand()
|
|
||||||
const language = useLanguage()
|
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const layout = useLayout()
|
|
||||||
const file = useFile()
|
|
||||||
const dialog = useDialog()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const serverSDK = useServerSDK()
|
|
||||||
const serverSync = useServerSync()
|
|
||||||
const { params, tabs, view } = useSessionLayout()
|
|
||||||
const filesOnly = () => props.mode === "files"
|
const filesOnly = () => props.mode === "files"
|
||||||
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
|
||||||
const [grouped, setGrouped] = createSignal(false)
|
|
||||||
const commandEntries = createCommandEntries({ filesOnly, command, language })
|
|
||||||
const fileEntries = createFileEntries({ file, tabs, language })
|
|
||||||
|
|
||||||
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
if (!filesOnly() && settings.general.newLayoutDesigns()) {
|
||||||
const project = createMemo(() => {
|
return <DialogCommandPaletteV2 onOpenFile={props.onOpenFile} />
|
||||||
const directory = projectDirectory()
|
|
||||||
if (!directory) return
|
|
||||||
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
|
|
||||||
})
|
|
||||||
const workspaces = createMemo(() => {
|
|
||||||
const directory = projectDirectory()
|
|
||||||
const current = project()
|
|
||||||
if (!current) return directory ? [directory] : []
|
|
||||||
|
|
||||||
const dirs = [current.worktree, ...(current.sandboxes ?? [])]
|
|
||||||
if (directory && !dirs.includes(directory)) return [...dirs, directory]
|
|
||||||
return dirs
|
|
||||||
})
|
|
||||||
const homedir = createMemo(() => serverSync().data.path.home)
|
|
||||||
const label = (directory: string) => {
|
|
||||||
const current = project()
|
|
||||||
const kind =
|
|
||||||
current && directory === current.worktree
|
|
||||||
? language.t("workspace.type.local")
|
|
||||||
: language.t("workspace.type.sandbox")
|
|
||||||
const [store] = serverSync().child(directory, { bootstrap: false })
|
|
||||||
const home = homedir()
|
|
||||||
const path = home ? directory.replace(home, "~") : directory
|
|
||||||
const name = store.vcs?.branch ?? getFilename(directory)
|
|
||||||
return `${kind} : ${name || path}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { sessions } = createSessionEntries({ workspaces, label, serverSDK: serverSDK(), language })
|
|
||||||
|
|
||||||
const items = async (text: string) => {
|
|
||||||
const query = text.trim()
|
|
||||||
setGrouped(query.length > 0)
|
|
||||||
|
|
||||||
if (!query && filesOnly()) {
|
|
||||||
const loaded = file.tree.state("")?.loaded
|
|
||||||
const pending = loaded ? Promise.resolve() : file.tree.list("")
|
|
||||||
const next = uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
|
|
||||||
|
|
||||||
if (loaded || next.length > 0) {
|
|
||||||
void pending
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
await pending
|
|
||||||
return uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!query) return [...commandEntries.picks(), ...fileEntries.recent()]
|
|
||||||
|
|
||||||
if (filesOnly()) {
|
|
||||||
const files = await file.searchFiles(query)
|
|
||||||
const category = language.t("palette.group.files")
|
|
||||||
return files.map((path) => createFileEntry(path, category))
|
|
||||||
}
|
|
||||||
|
|
||||||
const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))])
|
|
||||||
const category = language.t("palette.group.files")
|
|
||||||
const entries = files.map((path) => createFileEntry(path, category))
|
|
||||||
return [...commandEntries.list(), ...nextSessions, ...entries]
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMove = (item: Entry | undefined) => {
|
|
||||||
state.cleanup?.()
|
|
||||||
if (!item) return
|
|
||||||
if (item.type !== "command") return
|
|
||||||
state.cleanup = item.option?.onHighlight?.()
|
|
||||||
}
|
|
||||||
|
|
||||||
const open = (path: string) => {
|
|
||||||
const value = file.tab(path)
|
|
||||||
void tabs().open(value)
|
|
||||||
void file.load(path)
|
|
||||||
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
|
||||||
layout.fileTree.setTab("all")
|
|
||||||
props.onOpenFile?.(path)
|
|
||||||
tabs().setActive(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSelect = (item: Entry | undefined) => {
|
|
||||||
if (!item) return
|
|
||||||
state.committed = true
|
|
||||||
state.cleanup = undefined
|
|
||||||
dialog.close()
|
|
||||||
|
|
||||||
if (item.type === "command") {
|
|
||||||
item.option?.onSelect?.("palette")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.type === "session") {
|
|
||||||
if (!item.directory || !item.sessionID) return
|
|
||||||
navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!item.path) return
|
|
||||||
open(item.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
if (state.committed) return
|
|
||||||
state.cleanup?.()
|
|
||||||
})
|
|
||||||
|
|
||||||
if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
|
if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
|
||||||
|
return <DialogSelectFileDesktopV2 onOpenFile={props.onOpenFile} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <DialogSelectFileLegacy filesOnly={filesOnly} onOpenFile={props.onOpenFile} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogSelectFileDesktopV2(props: { onOpenFile?: (path: string) => void }) {
|
||||||
|
const language = useLanguage()
|
||||||
|
const serverSDK = useServerSDK()
|
||||||
|
const { params } = useSessionLayout()
|
||||||
|
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
||||||
|
const openFile = createCommandPaletteFileOpener(props.onOpenFile)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogSelectFileV2
|
<DialogSelectFileV2
|
||||||
server={serverSDK().server}
|
server={serverSDK().server}
|
||||||
@@ -400,32 +61,71 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
title={language.t("session.header.searchFiles")}
|
title={language.t("session.header.searchFiles")}
|
||||||
onSelect={(result) => {
|
onSelect={(result) => {
|
||||||
if (typeof result !== "string") return
|
if (typeof result !== "string") return
|
||||||
open(result)
|
openFile(result)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DialogSelectFileLegacy(props: { filesOnly: () => boolean; onOpenFile?: (path: string) => void }) {
|
||||||
|
const palette = createCommandPaletteModel(props)
|
||||||
|
const [grouped, setGrouped] = createSignal(false)
|
||||||
|
|
||||||
|
const items = async (text: string) => {
|
||||||
|
const query = text.trim()
|
||||||
|
setGrouped(query.length > 0)
|
||||||
|
|
||||||
|
if (!query && props.filesOnly()) {
|
||||||
|
const loaded = palette.file.tree.state("")?.loaded
|
||||||
|
const pending = loaded ? Promise.resolve() : palette.file.tree.list("")
|
||||||
|
const next = uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()])
|
||||||
|
|
||||||
|
if (loaded || next.length > 0) {
|
||||||
|
void pending
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
await pending
|
||||||
|
return uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!query) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
|
||||||
|
|
||||||
|
if (props.filesOnly()) {
|
||||||
|
const files = await palette.file.searchFiles(query)
|
||||||
|
const category = palette.language.t("palette.group.files")
|
||||||
|
return files.map((path) => createCommandPaletteFileEntry(path, category))
|
||||||
|
}
|
||||||
|
|
||||||
|
const [files, nextSessions] = await Promise.all([
|
||||||
|
palette.file.searchFiles(query),
|
||||||
|
Promise.resolve(palette.sessions(query)),
|
||||||
|
])
|
||||||
|
const category = palette.language.t("palette.group.files")
|
||||||
|
const entries = files.map((path) => createCommandPaletteFileEntry(path, category))
|
||||||
|
return [...palette.commandEntries(), ...nextSessions, ...entries]
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog class="pt-3 pb-0 !max-h-[480px]" transition>
|
<Dialog class="pt-3 pb-0 !max-h-[480px]" transition>
|
||||||
<List
|
<List
|
||||||
class="px-3"
|
class="px-3"
|
||||||
search={{
|
search={{
|
||||||
placeholder: filesOnly()
|
placeholder: props.filesOnly()
|
||||||
? language.t("session.header.searchFiles")
|
? palette.language.t("session.header.searchFiles")
|
||||||
: language.t("palette.search.placeholder"),
|
: palette.language.t("palette.search.placeholder"),
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
hideIcon: true,
|
hideIcon: true,
|
||||||
}}
|
}}
|
||||||
emptyMessage={language.t("palette.empty")}
|
emptyMessage={palette.language.t("palette.empty")}
|
||||||
loadingMessage={language.t("common.loading")}
|
loadingMessage={palette.language.t("common.loading")}
|
||||||
items={items}
|
items={items}
|
||||||
key={(item) => item.id}
|
key={(item) => item.id}
|
||||||
filterKeys={["title", "description", "category"]}
|
filterKeys={["title", "description", "category"]}
|
||||||
skipFilter={(item) => item.type === "file"}
|
skipFilter={(item) => item.type === "file"}
|
||||||
groupBy={grouped() ? (item) => item.category : () => ""}
|
groupBy={grouped() ? (item) => item.category : () => ""}
|
||||||
onMove={handleMove}
|
onMove={(item: CommandPaletteEntry | undefined) => palette.highlight(item)}
|
||||||
onSelect={handleSelect}
|
onSelect={(item: CommandPaletteEntry | undefined) => palette.select(item)}
|
||||||
>
|
>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<Switch
|
<Switch
|
||||||
@@ -452,7 +152,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Show when={item.keybind}>
|
<Show when={item.keybind}>
|
||||||
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "", language.t)}</Keybind>
|
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "", palette.language.t)}</Keybind>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
@@ -479,7 +179,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
</div>
|
</div>
|
||||||
<Show when={item.updated}>
|
<Show when={item.updated}>
|
||||||
<span class="text-12-regular text-text-weak whitespace-nowrap ml-2">
|
<span class="text-12-regular text-text-weak whitespace-nowrap ml-2">
|
||||||
{getRelativeTime(new Date(item.updated!).toISOString(), language.t)}
|
{getRelativeTime(new Date(item.updated!).toISOString(), palette.language.t)}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user