mirror of
https://github.com/sst/opencode.git
synced 2025-12-23 10:11:41 +00:00
wip(desktop): progress
This commit is contained in:
parent
56dde2cc83
commit
e9b95b2e91
4 changed files with 475 additions and 145 deletions
|
|
@ -12,6 +12,7 @@ import { GlobalSDKProvider } from "@/context/global-sdk"
|
|||
import { SessionProvider } from "@/context/session"
|
||||
import { NotificationProvider } from "@/context/notification"
|
||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||
import { CommandProvider } from "@/context/command"
|
||||
import Layout from "@/pages/layout"
|
||||
import Home from "@/pages/home"
|
||||
import DirectoryLayout from "@/pages/directory-layout"
|
||||
|
|
@ -40,27 +41,29 @@ export function App() {
|
|||
<GlobalSyncProvider>
|
||||
<LayoutProvider>
|
||||
<DialogProvider>
|
||||
<NotificationProvider>
|
||||
<MetaProvider>
|
||||
<Font />
|
||||
<Router root={Layout}>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/:dir" component={DirectoryLayout}>
|
||||
<Route path="/" component={() => <Navigate href="session" />} />
|
||||
<Route
|
||||
path="/session/:id?"
|
||||
component={(p) => (
|
||||
<Show when={p.params.id || true} keyed>
|
||||
<SessionProvider>
|
||||
<Session />
|
||||
</SessionProvider>
|
||||
</Show>
|
||||
)}
|
||||
/>
|
||||
</Route>
|
||||
</Router>
|
||||
</MetaProvider>
|
||||
</NotificationProvider>
|
||||
<CommandProvider>
|
||||
<NotificationProvider>
|
||||
<MetaProvider>
|
||||
<Font />
|
||||
<Router root={Layout}>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/:dir" component={DirectoryLayout}>
|
||||
<Route path="/" component={() => <Navigate href="session" />} />
|
||||
<Route
|
||||
path="/session/:id?"
|
||||
component={(p) => (
|
||||
<Show when={p.params.id || true} keyed>
|
||||
<SessionProvider>
|
||||
<Session />
|
||||
</SessionProvider>
|
||||
</Show>
|
||||
)}
|
||||
/>
|
||||
</Route>
|
||||
</Router>
|
||||
</MetaProvider>
|
||||
</NotificationProvider>
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
</LayoutProvider>
|
||||
</GlobalSyncProvider>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||
import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match } from "solid-js"
|
||||
import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makePersisted } from "@solid-primitives/storage"
|
||||
import { createFocusSignal } from "@solid-primitives/active-element"
|
||||
|
|
@ -19,6 +19,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||
import { DialogSelectModel } from "@/components/dialog-select-model"
|
||||
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useCommand, formatKeybind } from "@/context/command"
|
||||
|
||||
interface PromptInputProps {
|
||||
class?: string
|
||||
|
|
@ -53,6 +54,14 @@ const PLACEHOLDERS = [
|
|||
"How do environment variables work here?",
|
||||
]
|
||||
|
||||
interface SlashCommand {
|
||||
id: string
|
||||
trigger: string
|
||||
title: string
|
||||
description?: string
|
||||
keybind?: string
|
||||
}
|
||||
|
||||
export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const navigate = useNavigate()
|
||||
const sdk = useSDK()
|
||||
|
|
@ -61,18 +70,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
const session = useSession()
|
||||
const dialog = useDialog()
|
||||
const providers = useProviders()
|
||||
const command = useCommand()
|
||||
let editorRef!: HTMLDivElement
|
||||
|
||||
const [store, setStore] = createStore<{
|
||||
popoverIsOpen: boolean
|
||||
popover: "file" | "slash" | null
|
||||
historyIndex: number
|
||||
savedPrompt: Prompt | null
|
||||
placeholder: number
|
||||
slashFilter: string
|
||||
}>({
|
||||
popoverIsOpen: false,
|
||||
popover: null,
|
||||
historyIndex: -1,
|
||||
savedPrompt: null,
|
||||
placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
|
||||
slashFilter: "",
|
||||
})
|
||||
|
||||
const MAX_HISTORY = 100
|
||||
|
|
@ -157,17 +169,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
}
|
||||
|
||||
onMount(() => {
|
||||
editorRef.addEventListener("paste", handlePaste)
|
||||
editorRef?.addEventListener("paste", handlePaste)
|
||||
})
|
||||
onCleanup(() => {
|
||||
editorRef.removeEventListener("paste", handlePaste)
|
||||
editorRef?.removeEventListener("paste", handlePaste)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (isFocused()) {
|
||||
handleInput()
|
||||
} else {
|
||||
setStore("popoverIsOpen", false)
|
||||
setStore("popover", null)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -182,6 +194,53 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
onSelect: handleFileSelect,
|
||||
})
|
||||
|
||||
// Get slash commands from registered commands (only those with explicit slash trigger)
|
||||
const slashCommands = createMemo<SlashCommand[]>(() =>
|
||||
command.options
|
||||
.filter((opt) => !opt.disabled && !opt.id.startsWith("suggested.") && opt.slash)
|
||||
.map((opt) => ({
|
||||
id: opt.id,
|
||||
trigger: opt.slash!,
|
||||
title: opt.title,
|
||||
description: opt.description,
|
||||
keybind: opt.keybind,
|
||||
})),
|
||||
)
|
||||
|
||||
const handleSlashSelect = (cmd: SlashCommand | undefined) => {
|
||||
if (!cmd) return
|
||||
// Since slash commands only trigger from start, just clear the input
|
||||
editorRef.innerHTML = ""
|
||||
session.prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
|
||||
setStore("popover", null)
|
||||
command.trigger(cmd.id, "slash")
|
||||
}
|
||||
|
||||
const {
|
||||
flat: slashFlat,
|
||||
active: slashActive,
|
||||
onInput: slashOnInput,
|
||||
onKeyDown: slashOnKeyDown,
|
||||
} = useFilteredList<SlashCommand>({
|
||||
items: () => {
|
||||
const filter = store.slashFilter.toLowerCase()
|
||||
return slashCommands().filter(
|
||||
(cmd) =>
|
||||
cmd.trigger.toLowerCase().includes(filter) ||
|
||||
cmd.title.toLowerCase().includes(filter) ||
|
||||
cmd.description?.toLowerCase().includes(filter) ||
|
||||
false,
|
||||
)
|
||||
},
|
||||
key: (x) => x?.id,
|
||||
onSelect: handleSlashSelect,
|
||||
})
|
||||
|
||||
// Update slash filter when store changes
|
||||
createEffect(() => {
|
||||
slashOnInput(store.slashFilter)
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => session.prompt.current(),
|
||||
|
|
@ -256,11 +315,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
const rawText = rawParts.map((p) => p.content).join("")
|
||||
|
||||
const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/)
|
||||
// Slash commands only trigger when / is at the start of input
|
||||
const slashMatch = rawText.match(/^\/(\S*)$/)
|
||||
|
||||
if (atMatch) {
|
||||
onInput(atMatch[1])
|
||||
setStore("popoverIsOpen", true)
|
||||
} else if (store.popoverIsOpen) {
|
||||
setStore("popoverIsOpen", false)
|
||||
setStore("popover", "file")
|
||||
} else if (slashMatch) {
|
||||
setStore("slashFilter", slashMatch[1])
|
||||
setStore("popover", "slash")
|
||||
} else {
|
||||
setStore("popover", null)
|
||||
}
|
||||
|
||||
if (store.historyIndex >= 0) {
|
||||
|
|
@ -294,8 +359,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
const range = selection.getRangeAt(0)
|
||||
|
||||
if (atMatch) {
|
||||
// let node: Node | null = range.startContainer
|
||||
// let offset = range.startOffset
|
||||
let runningLength = 0
|
||||
|
||||
const walker = document.createTreeWalker(editorRef, NodeFilter.SHOW_TEXT, null)
|
||||
|
|
@ -335,7 +398,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
}
|
||||
|
||||
handleInput()
|
||||
setStore("popoverIsOpen", false)
|
||||
setStore("popover", null)
|
||||
}
|
||||
|
||||
const abort = () =>
|
||||
|
|
@ -403,8 +466,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (store.popoverIsOpen && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
|
||||
onKeyDown(event)
|
||||
// Handle popover navigation
|
||||
if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
|
||||
if (store.popover === "file") {
|
||||
onKeyDown(event)
|
||||
} else {
|
||||
slashOnKeyDown(event)
|
||||
}
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
|
@ -441,8 +509,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
handleSubmit(event)
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
if (store.popoverIsOpen) {
|
||||
setStore("popoverIsOpen", false)
|
||||
if (store.popover) {
|
||||
setStore("popover", null)
|
||||
} else if (session.working()) {
|
||||
abort()
|
||||
}
|
||||
|
|
@ -470,31 +538,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
}
|
||||
if (!existing) return
|
||||
|
||||
// if (!session.id) {
|
||||
// session.layout.setOpenedTabs(
|
||||
// session.layout.copyTabs("", session.id)
|
||||
// }
|
||||
|
||||
const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
|
||||
const attachments = prompt.filter((part) => part.type === "file")
|
||||
|
||||
// const activeFile = local.context.active()
|
||||
// if (activeFile) {
|
||||
// registerAttachment(
|
||||
// activeFile.path,
|
||||
// activeFile.selection,
|
||||
// activeFile.name ?? formatAttachmentLabel(activeFile.path, activeFile.selection),
|
||||
// )
|
||||
// }
|
||||
|
||||
// for (const contextFile of local.context.all()) {
|
||||
// registerAttachment(
|
||||
// contextFile.path,
|
||||
// contextFile.selection,
|
||||
// formatAttachmentLabel(contextFile.path, contextFile.selection),
|
||||
// )
|
||||
// }
|
||||
|
||||
const attachmentParts = attachments.map((attachment) => {
|
||||
const absolute = toAbsolutePath(attachment.path)
|
||||
const query = attachment.selection
|
||||
|
|
@ -519,7 +565,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
|
||||
session.layout.setActiveTab(undefined)
|
||||
session.messages.setActive(undefined)
|
||||
// Clear the editor DOM directly to ensure it's empty
|
||||
editorRef.innerHTML = ""
|
||||
session.prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
|
||||
|
||||
|
|
@ -542,38 +587,66 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
|
||||
return (
|
||||
<div class="relative size-full _max-h-[320px] flex flex-col gap-3">
|
||||
<Show when={store.popoverIsOpen}>
|
||||
{/* Popover for file mentions and slash commands */}
|
||||
<Show when={store.popover}>
|
||||
<div
|
||||
class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-[252px] min-h-10
|
||||
overflow-auto no-scrollbar flex flex-col p-2 pb-0 rounded-md
|
||||
border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
|
||||
>
|
||||
<Show when={flat().length > 0} fallback={<div class="text-text-weak px-2">No matching files</div>}>
|
||||
<For each={flat()}>
|
||||
{(i) => (
|
||||
<button
|
||||
classList={{
|
||||
"w-full flex items-center justify-between rounded-md": true,
|
||||
"bg-surface-raised-base-hover": active() === i,
|
||||
}}
|
||||
onClick={() => handleFileSelect(i)}
|
||||
>
|
||||
<div class="flex items-center gap-x-2 grow min-w-0">
|
||||
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
|
||||
<div class="flex items-center text-14-regular">
|
||||
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
||||
{getDirectory(i)}
|
||||
</span>
|
||||
<Show when={!i.endsWith("/")}>
|
||||
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
|
||||
<Switch>
|
||||
<Match when={store.popover === "file"}>
|
||||
<Show when={flat().length > 0} fallback={<div class="text-text-weak px-2 py-1">No matching files</div>}>
|
||||
<For each={flat()}>
|
||||
{(i) => (
|
||||
<button
|
||||
classList={{
|
||||
"w-full flex items-center gap-x-2 rounded-md px-2 py-1": true,
|
||||
"bg-surface-raised-base-hover": active() === i,
|
||||
}}
|
||||
onClick={() => handleFileSelect(i)}
|
||||
>
|
||||
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
|
||||
<div class="flex items-center text-14-regular min-w-0">
|
||||
<span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(i)}</span>
|
||||
<Show when={!i.endsWith("/")}>
|
||||
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={store.popover === "slash"}>
|
||||
<Show
|
||||
when={slashFlat().length > 0}
|
||||
fallback={<div class="text-text-weak px-2 py-1">No matching commands</div>}
|
||||
>
|
||||
<For each={slashFlat()}>
|
||||
{(cmd) => (
|
||||
<button
|
||||
classList={{
|
||||
"w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true,
|
||||
"bg-surface-raised-base-hover": slashActive() === cmd.id,
|
||||
}}
|
||||
onClick={() => handleSlashSelect(cmd)}
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span>
|
||||
<Show when={cmd.description}>
|
||||
<span class="text-14-regular text-text-weak truncate">{cmd.description}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={cmd.keybind}>
|
||||
<span class="text-12-regular text-text-subtle shrink-0">{formatKeybind(cmd.keybind!)}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</Show>
|
||||
<form
|
||||
|
|
|
|||
255
packages/desktop/src/context/command.tsx
Normal file
255
packages/desktop/src/context/command.tsx
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
import { createMemo, createSignal, onCleanup, onMount, Show, type Accessor } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
|
||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
|
||||
/**
|
||||
* Keybind configuration type.
|
||||
* Format: "mod+key" where mod can be ctrl, alt, shift, meta (or cmd on mac)
|
||||
* Multiple keybinds can be separated by comma: "mod+p,ctrl+shift+p"
|
||||
* Use "mod" for platform-appropriate modifier (cmd on mac, ctrl elsewhere)
|
||||
*/
|
||||
export type KeybindConfig = string
|
||||
|
||||
export interface Keybind {
|
||||
key: string
|
||||
ctrl: boolean
|
||||
meta: boolean
|
||||
shift: boolean
|
||||
alt: boolean
|
||||
}
|
||||
|
||||
export interface CommandOption {
|
||||
/** Unique identifier for the command */
|
||||
id: string
|
||||
/** Display title in the command palette */
|
||||
title: string
|
||||
/** Optional description */
|
||||
description?: string
|
||||
/** Category for grouping in the palette */
|
||||
category?: string
|
||||
/** Keybind string (e.g., "mod+p", "ctrl+shift+t") */
|
||||
keybind?: KeybindConfig
|
||||
/** Slash command trigger (e.g., "models" for /models) */
|
||||
slash?: string
|
||||
/** Whether to show in the "Suggested" section */
|
||||
suggested?: boolean
|
||||
/** Whether the command is disabled */
|
||||
disabled?: boolean
|
||||
/** Handler when command is selected */
|
||||
onSelect?: (source?: "palette" | "keybind" | "slash") => void
|
||||
}
|
||||
|
||||
export function parseKeybind(config: string): Keybind[] {
|
||||
if (!config || config === "none") return []
|
||||
|
||||
return config.split(",").map((combo) => {
|
||||
const parts = combo.trim().toLowerCase().split("+")
|
||||
const keybind: Keybind = {
|
||||
key: "",
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
alt: false,
|
||||
}
|
||||
|
||||
for (const part of parts) {
|
||||
switch (part) {
|
||||
case "ctrl":
|
||||
case "control":
|
||||
keybind.ctrl = true
|
||||
break
|
||||
case "meta":
|
||||
case "cmd":
|
||||
case "command":
|
||||
keybind.meta = true
|
||||
break
|
||||
case "mod":
|
||||
if (IS_MAC) keybind.meta = true
|
||||
else keybind.ctrl = true
|
||||
break
|
||||
case "alt":
|
||||
case "option":
|
||||
keybind.alt = true
|
||||
break
|
||||
case "shift":
|
||||
keybind.shift = true
|
||||
break
|
||||
default:
|
||||
keybind.key = part
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return keybind
|
||||
})
|
||||
}
|
||||
|
||||
export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean {
|
||||
const eventKey = event.key.toLowerCase()
|
||||
|
||||
for (const kb of keybinds) {
|
||||
const keyMatch = kb.key === eventKey
|
||||
const ctrlMatch = kb.ctrl === (event.ctrlKey || false)
|
||||
const metaMatch = kb.meta === (event.metaKey || false)
|
||||
const shiftMatch = kb.shift === (event.shiftKey || false)
|
||||
const altMatch = kb.alt === (event.altKey || false)
|
||||
|
||||
if (keyMatch && ctrlMatch && metaMatch && shiftMatch && altMatch) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function formatKeybind(config: string): string {
|
||||
if (!config || config === "none") return ""
|
||||
|
||||
const keybinds = parseKeybind(config)
|
||||
if (keybinds.length === 0) return ""
|
||||
|
||||
const kb = keybinds[0]
|
||||
const parts: string[] = []
|
||||
|
||||
if (kb.ctrl) parts.push(IS_MAC ? "⌃" : "Ctrl")
|
||||
if (kb.alt) parts.push(IS_MAC ? "⌥" : "Alt")
|
||||
if (kb.shift) parts.push(IS_MAC ? "⇧" : "Shift")
|
||||
if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta")
|
||||
|
||||
if (kb.key) {
|
||||
const displayKey = kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1)
|
||||
parts.push(displayKey)
|
||||
}
|
||||
|
||||
return IS_MAC ? parts.join("") : parts.join("+")
|
||||
}
|
||||
|
||||
function DialogCommand(props: { options: CommandOption[] }) {
|
||||
const dialog = useDialog()
|
||||
|
||||
return (
|
||||
<Dialog title="Commands">
|
||||
<List
|
||||
class="px-2.5"
|
||||
search={{ placeholder: "Search commands", autofocus: true }}
|
||||
emptyMessage="No commands found"
|
||||
items={() => props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)}
|
||||
key={(x) => x.id}
|
||||
groupBy={(x) => x.category ?? ""}
|
||||
onSelect={(option) => {
|
||||
if (option) {
|
||||
dialog.clear()
|
||||
option.onSelect?.("palette")
|
||||
}
|
||||
}}
|
||||
>
|
||||
{(option) => (
|
||||
<div class="w-full flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-14-regular text-text-strong whitespace-nowrap">{option.title}</span>
|
||||
<Show when={option.description}>
|
||||
<span class="text-14-regular text-text-weak truncate">{option.description}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={option.keybind}>
|
||||
<span class="text-12-regular text-text-subtle shrink-0">{formatKeybind(option.keybind!)}</span>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
|
||||
name: "Command",
|
||||
init: () => {
|
||||
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
|
||||
const [suspendCount, setSuspendCount] = createSignal(0)
|
||||
const dialog = useDialog()
|
||||
|
||||
const options = createMemo(() => {
|
||||
const all = registrations().flatMap((x) => x())
|
||||
const suggested = all.filter((x) => x.suggested && !x.disabled)
|
||||
return [
|
||||
...suggested.map((x) => ({
|
||||
...x,
|
||||
id: "suggested." + x.id,
|
||||
category: "Suggested",
|
||||
})),
|
||||
...all,
|
||||
]
|
||||
})
|
||||
|
||||
const suspended = () => suspendCount() > 0
|
||||
|
||||
const showPalette = () => {
|
||||
if (dialog.stack.length === 0) {
|
||||
dialog.replace(() => <DialogCommand options={options().filter((x) => !x.disabled)} />)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (suspended()) return
|
||||
|
||||
// Check for command palette keybind (mod+shift+p)
|
||||
const paletteKeybinds = parseKeybind("mod+shift+p")
|
||||
if (matchKeybind(paletteKeybinds, event)) {
|
||||
event.preventDefault()
|
||||
showPalette()
|
||||
return
|
||||
}
|
||||
|
||||
// Check registered command keybinds
|
||||
for (const option of options()) {
|
||||
if (option.disabled) continue
|
||||
if (!option.keybind) continue
|
||||
|
||||
const keybinds = parseKeybind(option.keybind)
|
||||
if (matchKeybind(keybinds, event)) {
|
||||
event.preventDefault()
|
||||
option.onSelect?.("keybind")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
|
||||
return {
|
||||
register(cb: () => CommandOption[]) {
|
||||
const results = createMemo(cb)
|
||||
setRegistrations((arr) => [results, ...arr])
|
||||
onCleanup(() => {
|
||||
setRegistrations((arr) => arr.filter((x) => x !== results))
|
||||
})
|
||||
},
|
||||
trigger(id: string, source?: "palette" | "keybind" | "slash") {
|
||||
for (const option of options()) {
|
||||
if (option.id === id || option.id === "suggested." + id) {
|
||||
option.onSelect?.(source)
|
||||
return
|
||||
}
|
||||
}
|
||||
},
|
||||
show: showPalette,
|
||||
keybinds(enabled: boolean) {
|
||||
setSuspendCount((count) => count + (enabled ? -1 : 1))
|
||||
},
|
||||
suspended,
|
||||
get options() {
|
||||
return options()
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -34,6 +34,7 @@ import { Terminal } from "@/components/terminal"
|
|||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { DialogSelectFile } from "@/components/dialog-select-file"
|
||||
import { useCommand } from "@/context/command"
|
||||
|
||||
export default function Page() {
|
||||
const layout = useLayout()
|
||||
|
|
@ -41,6 +42,7 @@ export default function Page() {
|
|||
const sync = useSync()
|
||||
const session = useSession()
|
||||
const dialog = useDialog()
|
||||
const command = useCommand()
|
||||
const [store, setStore] = createStore({
|
||||
clickTimer: undefined as number | undefined,
|
||||
activeDraggable: undefined as string | undefined,
|
||||
|
|
@ -48,16 +50,6 @@ export default function Page() {
|
|||
})
|
||||
let inputRef!: HTMLDivElement
|
||||
|
||||
const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (layout.terminal.opened()) {
|
||||
if (session.terminal.all().length === 0) {
|
||||
|
|
@ -66,35 +58,54 @@ export default function Page() {
|
|||
}
|
||||
})
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.getModifierState(MOD) && event.shiftKey && event.key.toLowerCase() === "p") {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (event.getModifierState(MOD) && event.key.toLowerCase() === "p") {
|
||||
event.preventDefault()
|
||||
dialog.replace(() => <DialogSelectFile />)
|
||||
return
|
||||
}
|
||||
if (event.ctrlKey && event.key.toLowerCase() === "t") {
|
||||
event.preventDefault()
|
||||
const currentTheme = localStorage.getItem("theme") ?? "oc-1"
|
||||
const themes = ["oc-1", "oc-2-paper"]
|
||||
const nextTheme = themes[(themes.indexOf(currentTheme) + 1) % themes.length]
|
||||
localStorage.setItem("theme", nextTheme)
|
||||
document.documentElement.setAttribute("data-theme", nextTheme)
|
||||
return
|
||||
}
|
||||
if (event.ctrlKey && event.key.toLowerCase() === "`") {
|
||||
event.preventDefault()
|
||||
if (event.shiftKey) {
|
||||
session.terminal.new()
|
||||
return
|
||||
}
|
||||
layout.terminal.toggle()
|
||||
return
|
||||
}
|
||||
// Register commands for this page
|
||||
command.register(() => [
|
||||
{
|
||||
id: "file.open",
|
||||
title: "Open file",
|
||||
description: "Search and open a file",
|
||||
category: "File",
|
||||
keybind: "mod+p",
|
||||
slash: "open",
|
||||
onSelect: () => dialog.replace(() => <DialogSelectFile />),
|
||||
},
|
||||
{
|
||||
id: "theme.toggle",
|
||||
title: "Toggle theme",
|
||||
description: "Switch between themes",
|
||||
category: "View",
|
||||
keybind: "ctrl+t",
|
||||
slash: "theme",
|
||||
onSelect: () => {
|
||||
const currentTheme = localStorage.getItem("theme") ?? "oc-1"
|
||||
const themes = ["oc-1", "oc-2-paper"]
|
||||
const nextTheme = themes[(themes.indexOf(currentTheme) + 1) % themes.length]
|
||||
localStorage.setItem("theme", nextTheme)
|
||||
document.documentElement.setAttribute("data-theme", nextTheme)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "terminal.toggle",
|
||||
title: "Toggle terminal",
|
||||
description: "Show or hide the terminal",
|
||||
category: "View",
|
||||
keybind: "ctrl+`",
|
||||
slash: "terminal",
|
||||
onSelect: () => layout.terminal.toggle(),
|
||||
},
|
||||
{
|
||||
id: "terminal.new",
|
||||
title: "New terminal",
|
||||
description: "Create a new terminal tab",
|
||||
category: "Terminal",
|
||||
keybind: "ctrl+shift+`",
|
||||
onSelect: () => session.terminal.new(),
|
||||
},
|
||||
])
|
||||
|
||||
// Handle keyboard events that aren't commands
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Don't interfere with terminal
|
||||
// @ts-expect-error
|
||||
if (document.activeElement?.dataset?.component === "terminal") {
|
||||
return
|
||||
|
|
@ -108,32 +119,20 @@ export default function Page() {
|
|||
return
|
||||
}
|
||||
|
||||
// if (local.file.active()) {
|
||||
// const active = local.file.active()!
|
||||
// if (event.key === "Enter" && active.selection) {
|
||||
// local.context.add({
|
||||
// type: "file",
|
||||
// path: active.path,
|
||||
// selection: { ...active.selection },
|
||||
// })
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// if (event.getModifierState(MOD)) {
|
||||
// if (event.key.toLowerCase() === "a") {
|
||||
// return
|
||||
// }
|
||||
// if (event.key.toLowerCase() === "c") {
|
||||
// return
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// Focus input when typing characters
|
||||
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
|
||||
inputRef?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
|
||||
const resetClickTimer = () => {
|
||||
if (!store.clickTimer) return
|
||||
clearTimeout(store.clickTimer)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue