feat(tui): project picker with footer typewriter flash
This commit is contained in:
@@ -66,6 +66,7 @@ import { DialogThemeList } from "./component/dialog-theme-list"
|
||||
import { DialogHelp } from "./ui/dialog-help"
|
||||
import { DialogAgent } from "./component/dialog-agent"
|
||||
import { DialogSessionList } from "./component/dialog-session-list"
|
||||
import { DialogProject } from "./component/dialog-project"
|
||||
import { SessionTabs } from "./component/session-tabs"
|
||||
import { ThemeErrorToast } from "./component/theme-error-toast"
|
||||
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
|
||||
@@ -648,6 +649,15 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "project.switch",
|
||||
title: "Switch project",
|
||||
category: "Session",
|
||||
slash: { name: "projects", aliases: ["project"] },
|
||||
run: () => {
|
||||
dialog.replace(() => <DialogProject />)
|
||||
},
|
||||
},
|
||||
...Array.from({ length: 9 }, (_, i) => ({
|
||||
name: `session.quick_switch.${i + 1}`,
|
||||
title: `Switch to session in quick slot ${i + 1}`,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import path from "path"
|
||||
import { createMemo, createResource } from "solid-js"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useClient } from "../context/client"
|
||||
import { useData } from "../context/data"
|
||||
import { useRoute } from "../context/route"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { abbreviateHome } from "../runtime"
|
||||
import { useTuiPaths } from "../context/runtime"
|
||||
import { errorMessage } from "../util/error"
|
||||
|
||||
export function DialogProject() {
|
||||
const dialog = useDialog()
|
||||
const client = useClient()
|
||||
const data = useData()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
const paths = useTuiPaths()
|
||||
|
||||
const [projects] = createResource(() => client.api.project.list())
|
||||
const current = createMemo(() => data.location.info()?.project.directory ?? data.location.default().directory)
|
||||
|
||||
const options = createMemo(() => {
|
||||
const list = [...(projects() ?? [])]
|
||||
list.sort((a, b) => {
|
||||
if (a.worktree === current()) return -1
|
||||
if (b.worktree === current()) return 1
|
||||
return (b.time.initialized ?? 0) - (a.time.initialized ?? 0)
|
||||
})
|
||||
return list
|
||||
.filter((project) => project.worktree !== "/")
|
||||
.filter((project, index, all) => all.findIndex((other) => other.worktree === project.worktree) === index)
|
||||
.map((project) => ({
|
||||
title: project.name ?? path.basename(project.worktree),
|
||||
description: abbreviateHome(project.worktree, paths.home),
|
||||
value: project.worktree,
|
||||
category: project.worktree === current() ? "Current" : "Projects",
|
||||
}))
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Switch project"
|
||||
placeholder="Search projects…"
|
||||
options={options()}
|
||||
current={current()}
|
||||
emptyView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text>{projects.loading ? "Loading projects…" : "No projects found"}</text>
|
||||
</box>
|
||||
}
|
||||
onSelect={(option) => {
|
||||
dialog.clear()
|
||||
if (option.value === current()) return
|
||||
void data.location
|
||||
.setDefault(option.value)
|
||||
.then(() => route.navigate({ type: "home" }))
|
||||
.catch((error) =>
|
||||
toast.show({ variant: "error", title: "Failed to switch project", message: errorMessage(error) }),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1116,6 +1116,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
default() {
|
||||
return defaultLocation()
|
||||
},
|
||||
// Repoints the whole TUI at another project directory, like a shell `cd`.
|
||||
// The follow-up default sync lets the server canonicalize the directory.
|
||||
async setDefault(directory: string) {
|
||||
setDefaultLocation({ directory })
|
||||
await result.location.sync()
|
||||
},
|
||||
async sync(ref?: LocationRef) {
|
||||
const current = ref ?? defaultLocation()
|
||||
await sync.run(`location:${locationKey(current)}`, async () => {
|
||||
|
||||
@@ -3,15 +3,26 @@ import { createMemo, Match, Show, Switch } from "solid-js"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { FilePath } from "../../ui/file-path"
|
||||
import { stringWidth } from "../../util/string-width"
|
||||
import { createTypewriter } from "../../ui/typewriter"
|
||||
|
||||
function Directory(props: { context: Plugin.Context; maxWidth: number }) {
|
||||
const directory = createMemo(() =>
|
||||
props.context.location ? props.context.ui.format.path(props.context.location.directory) : undefined,
|
||||
)
|
||||
const typed = createTypewriter(directory)
|
||||
|
||||
return (
|
||||
<Show when={directory()}>
|
||||
{(value) => <FilePath value={value()} maxWidth={props.maxWidth} fg={props.context.theme.text.subdued} />}
|
||||
<Show when={typed.text !== undefined}>
|
||||
<box flexDirection="row" flexShrink={1}>
|
||||
<FilePath
|
||||
value={typed.text ?? ""}
|
||||
maxWidth={props.maxWidth}
|
||||
fg={typed.active ? props.context.theme.text.default : props.context.theme.text.subdued}
|
||||
/>
|
||||
<Show when={typed.active}>
|
||||
<text fg={props.context.theme.text.default}>│</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { FilePath } from "../../ui/file-path"
|
||||
import { createTypewriter } from "../../ui/typewriter"
|
||||
|
||||
function View(props: { context: Plugin.Context }) {
|
||||
const directory = createMemo(() =>
|
||||
props.context.location ? props.context.ui.format.path(props.context.location.directory) : undefined,
|
||||
)
|
||||
const typed = createTypewriter(directory)
|
||||
return (
|
||||
<Show when={directory()}>
|
||||
{(value) => <FilePath value={value()} maxWidth={38} fg={props.context.theme.text.subdued} />}
|
||||
<Show when={typed.text !== undefined}>
|
||||
<box flexDirection="row" flexShrink={1}>
|
||||
<FilePath
|
||||
value={typed.text ?? ""}
|
||||
maxWidth={38}
|
||||
fg={typed.active ? props.context.theme.text.default : props.context.theme.text.subdued}
|
||||
/>
|
||||
<Show when={typed.active}>
|
||||
<text fg={props.context.theme.text.default}>│</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { createEffect, on, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useConfig } from "../config"
|
||||
|
||||
// Retypes a string like a typewriter whenever the source changes. The initial
|
||||
// value renders immediately; only subsequent changes animate. While `active`,
|
||||
// callers should render a cursor and a brighter style, then settle back down.
|
||||
export function createTypewriter(source: () => string | undefined) {
|
||||
const config = useConfig().data
|
||||
const [store, setStore] = createStore({
|
||||
text: source(),
|
||||
active: false,
|
||||
})
|
||||
createEffect(
|
||||
on(
|
||||
source,
|
||||
(text) => {
|
||||
if (text === undefined || !(config.animations ?? true)) {
|
||||
setStore({ text, active: false })
|
||||
return
|
||||
}
|
||||
const timeouts: ReturnType<typeof setTimeout>[] = []
|
||||
setStore({ text: "", active: true })
|
||||
let i = 0
|
||||
const type = () => {
|
||||
if (i < text.length) {
|
||||
i++
|
||||
setStore("text", text.slice(0, i))
|
||||
timeouts.push(setTimeout(type, Math.random() < 0.1 ? 40 + Math.random() * 40 : 8 + Math.random() * 18))
|
||||
return
|
||||
}
|
||||
timeouts.push(setTimeout(() => setStore("active", false), 1200))
|
||||
}
|
||||
timeouts.push(setTimeout(type, 120))
|
||||
onCleanup(() => timeouts.forEach(clearTimeout))
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
return store
|
||||
}
|
||||
Reference in New Issue
Block a user