mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 06:15:14 +00:00
feat(tui): add open menu for sessions and projects (#39752)
This commit is contained in:
parent
b866900417
commit
146fdb9de1
7 changed files with 178 additions and 115 deletions
|
|
@ -66,7 +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 { DialogOpen } from "./component/dialog-open"
|
||||
import { SessionTabs } from "./component/session-tabs"
|
||||
import { ThemeErrorToast } from "./component/theme-error-toast"
|
||||
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
|
||||
|
|
@ -95,13 +95,11 @@ import { StorageProvider } from "./context/storage"
|
|||
|
||||
registerOpencodeSpinner()
|
||||
|
||||
const appGlobalBindingCommands = ["session.list", "session.new"] as const
|
||||
const appGlobalBindingCommands = ["session.list", "session.new", "open.menu"] as const
|
||||
|
||||
const sessionTabBindingCommands = [
|
||||
"session.tab.next",
|
||||
"session.tab.previous",
|
||||
"session.tab.history.back",
|
||||
"session.tab.history.forward",
|
||||
"session.tab.next_unread",
|
||||
"session.tab.previous_unread",
|
||||
"session.tab.close",
|
||||
|
|
@ -653,12 +651,12 @@ function App(props: { pair?: DialogPairCredentials }) {
|
|||
},
|
||||
},
|
||||
{
|
||||
name: "project.switch",
|
||||
title: "Switch project",
|
||||
name: "open.menu",
|
||||
title: "Open session or project",
|
||||
category: "Session",
|
||||
slash: { name: "projects", aliases: ["project"] },
|
||||
slash: { name: "open", aliases: ["projects", "project"] },
|
||||
run: () => {
|
||||
dialog.replace(() => <DialogProject />)
|
||||
dialog.replace(() => <DialogOpen />)
|
||||
},
|
||||
},
|
||||
...Array.from({ length: 9 }, (_, i) => ({
|
||||
|
|
@ -685,22 +683,6 @@ function App(props: { pair?: DialogPairCredentials }) {
|
|||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.cycle(-1),
|
||||
},
|
||||
{
|
||||
name: "session.tab.history.back",
|
||||
title: "Back in tab history",
|
||||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.history(-1),
|
||||
},
|
||||
{
|
||||
name: "session.tab.history.forward",
|
||||
title: "Forward in tab history",
|
||||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.history(1),
|
||||
},
|
||||
{
|
||||
name: "session.tab.next_unread",
|
||||
title: "Next unread tab",
|
||||
|
|
|
|||
164
packages/tui/src/component/dialog-open.tsx
Normal file
164
packages/tui/src/component/dialog-open.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import path from "path"
|
||||
import { createMemo, createResource, createSignal, onMount } from "solid-js"
|
||||
import type { SessionInfo } from "@opencode-ai/client"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useRoute } from "../context/route"
|
||||
import { useData } from "../context/data"
|
||||
import { useClient } from "../context/client"
|
||||
import { useLocation } from "../context/location"
|
||||
import { useSessionTabs } from "../context/session-tabs"
|
||||
import { useTheme, useThemes } from "../context/theme"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { Locale } from "../util/locale"
|
||||
import { abbreviateHome } from "../runtime"
|
||||
import { useTuiPaths } from "../context/runtime"
|
||||
import { truncateFilePath } from "../ui/file-path"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { Spinner } from "./spinner"
|
||||
|
||||
const RECENT_LIMIT = 8
|
||||
|
||||
type OpenTarget = { type: "session"; sessionID: string } | { type: "project"; directory: string }
|
||||
|
||||
export function DialogOpen() {
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
const data = useData()
|
||||
const client = useClient()
|
||||
const location = useLocation()
|
||||
const sessionTabs = useSessionTabs()
|
||||
const themes = useThemes()
|
||||
const theme = useTheme("elevated")
|
||||
const mode = themes.mode
|
||||
const paths = useTuiPaths()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const [filter, setFilter] = createSignal("")
|
||||
|
||||
data.project.invalidate()
|
||||
void data.project.sync().catch(() => {})
|
||||
|
||||
// One background fetch fills in recent sessions from other projects; the menu renders
|
||||
// immediately from the local store and never blocks on the network.
|
||||
const [fetched] = createResource(
|
||||
() =>
|
||||
client.api.session
|
||||
.list({ limit: 50, order: "desc", parentID: null })
|
||||
.then((response) => response.data)
|
||||
.catch(() => [] as SessionInfo[]),
|
||||
{ initialValue: [] },
|
||||
)
|
||||
|
||||
const openTabs = createMemo(() => new Set(sessionTabs.tabs().map((tab) => tab.sessionID)))
|
||||
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
|
||||
const sessions = createMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
return [...data.session.list(), ...fetched()]
|
||||
.filter((session) => {
|
||||
if (session.parentID || seen.has(session.id)) return false
|
||||
seen.add(session.id)
|
||||
return true
|
||||
})
|
||||
.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
})
|
||||
|
||||
const options = createMemo(() => {
|
||||
const tabs = openTabs()
|
||||
// With an empty query the menu shows what is not already one keystroke away: open tabs are
|
||||
// visible in the strip, so recents exclude them. Typing widens the pool to every session so
|
||||
// matching a tab by name still switches to it.
|
||||
const recent = filter().trim()
|
||||
? sessions()
|
||||
: sessions()
|
||||
.filter((session) => !tabs.has(session.id))
|
||||
.slice(0, RECENT_LIMIT)
|
||||
const sessionOptions = recent.map((session) => {
|
||||
const project = data.project.get(session.projectID)
|
||||
const name = project?.name || path.basename(project?.canonical ?? session.location.directory)
|
||||
const running = data.session.family(session.id).some((id) => data.session.status(id) === "running")
|
||||
return {
|
||||
title: session.title,
|
||||
value: { type: "session", sessionID: session.id } as OpenTarget,
|
||||
category: "Sessions",
|
||||
footer: `${Locale.truncate(name, 20)} · ${timeAgo(session.time.updated)}`,
|
||||
gutter: running
|
||||
? () => <Spinner />
|
||||
: tabs.has(session.id)
|
||||
? () => <text fg={theme.hue.accent[mode() === "light" ? 800 : 200]}>▪</text>
|
||||
: undefined,
|
||||
}
|
||||
})
|
||||
|
||||
const current = location.current?.project
|
||||
const seen = new Set<string>()
|
||||
const projectOptions = data.project
|
||||
.list()
|
||||
.filter((project) => {
|
||||
if (project.canonical === "/" || project.id === current?.id || seen.has(project.canonical)) return false
|
||||
seen.add(project.canonical)
|
||||
return true
|
||||
})
|
||||
.map((project) => {
|
||||
const title = project.name ?? path.basename(project.canonical)
|
||||
const description = abbreviateHome(project.canonical, paths.home)
|
||||
// Dialog padding, the gutter column, title padding, and the separating space use nine columns.
|
||||
const width = Math.min(60, dimensions().width - 2) - 9 - stringWidth(title)
|
||||
return {
|
||||
title,
|
||||
description: truncateFilePath(description, width),
|
||||
searchText: description,
|
||||
value: { type: "project", directory: project.canonical } as OpenTarget,
|
||||
category: "Projects",
|
||||
}
|
||||
})
|
||||
|
||||
return [...sessionOptions, ...projectOptions]
|
||||
})
|
||||
|
||||
onMount(() => dialog.setSize("large"))
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Open"
|
||||
placeholder="Search sessions and projects…"
|
||||
options={options()}
|
||||
current={currentSessionID() ? ({ type: "session", sessionID: currentSessionID()! } as OpenTarget) : undefined}
|
||||
focusCurrent={false}
|
||||
onFilter={setFilter}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<text fg={theme.text.subdued}>
|
||||
{shortcuts.get("session.list")
|
||||
? `No matches · search all sessions with ${shortcuts.get("session.list")}`
|
||||
: "No matches"}
|
||||
</text>
|
||||
</box>
|
||||
}
|
||||
onSelect={(option) => {
|
||||
dialog.clear()
|
||||
if (option.value.type === "session") {
|
||||
route.navigate({ type: "session", sessionID: option.value.sessionID })
|
||||
return
|
||||
}
|
||||
const target = { directory: option.value.directory }
|
||||
route.navigate({ type: "home", location: target })
|
||||
location.set(target)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function timeAgo(timestamp: number) {
|
||||
const minutes = Math.floor((Date.now() - timestamp) / 60_000)
|
||||
if (minutes < 1) return "now"
|
||||
if (minutes < 60) return `${minutes}m`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 30) return `${days}d`
|
||||
const months = Math.floor(days / 30)
|
||||
if (months < 12) return `${months}mo`
|
||||
return `${Math.floor(days / 365)}y`
|
||||
}
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
import path from "path"
|
||||
import { createMemo } from "solid-js"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useData } from "../context/data"
|
||||
import { useRoute } from "../context/route"
|
||||
import { abbreviateHome } from "../runtime"
|
||||
import { useTuiPaths } from "../context/runtime"
|
||||
import { useLocation } from "../context/location"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { truncateFilePath } from "../ui/file-path"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
|
||||
export function DialogProject() {
|
||||
const dialog = useDialog()
|
||||
const data = useData()
|
||||
const route = useRoute()
|
||||
const paths = useTuiPaths()
|
||||
const location = useLocation()
|
||||
const toast = useToast()
|
||||
const dimensions = useTerminalDimensions()
|
||||
|
||||
data.project.invalidate()
|
||||
void data.project.sync().catch(toast.error)
|
||||
|
||||
const current = () => location.current?.project
|
||||
|
||||
const options = createMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
return data.project
|
||||
.list()
|
||||
.filter((project) => {
|
||||
if (project.canonical === "/" || seen.has(project.canonical)) return false
|
||||
seen.add(project.canonical)
|
||||
return true
|
||||
})
|
||||
.toSorted((a, b) => {
|
||||
if (a.id === current()?.id) return -1
|
||||
if (b.id === current()?.id) return 1
|
||||
return 0
|
||||
})
|
||||
.map((project) => {
|
||||
const title = project.name ?? path.basename(project.canonical)
|
||||
const description = abbreviateHome(project.canonical, paths.home)
|
||||
// Dialog padding, the current marker, title padding, and the separating space use nine columns.
|
||||
const width = Math.min(60, dimensions().width - 2) - 9 - stringWidth(title)
|
||||
return {
|
||||
title,
|
||||
description: truncateFilePath(description, width),
|
||||
searchText: description,
|
||||
value: project.canonical,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Switch project"
|
||||
placeholder="Search projects…"
|
||||
options={options()}
|
||||
current={current()?.canonical}
|
||||
emptyView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text>No projects found</text>
|
||||
</box>
|
||||
}
|
||||
onSelect={(option) => {
|
||||
dialog.clear()
|
||||
if (option.value === current()?.canonical) return
|
||||
const target = { directory: option.value }
|
||||
route.navigate({ type: "home", location: target })
|
||||
location.set(target)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import { DialogSessionRename } from "./dialog-session-rename"
|
|||
import { Spinner } from "./spinner"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { useSessionTabs } from "../context/session-tabs"
|
||||
import { useStorage } from "../context/storage"
|
||||
|
||||
export function DialogSessionList() {
|
||||
const dialog = useDialog()
|
||||
|
|
@ -33,7 +34,8 @@ export function DialogSessionList() {
|
|||
const shortcuts = Keymap.useShortcuts()
|
||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
const [allProjects, setAllProjects] = createSignal(false)
|
||||
const [prefs, updatePrefs] = useStorage().store("session-list", { initial: { allProjects: false } })
|
||||
const allProjects = () => prefs.allProjects
|
||||
|
||||
const [searchResults, { mutate: setSearchResults }] = createResource(
|
||||
() => ({ query: search().trim(), allProjects: allProjects() }),
|
||||
|
|
@ -189,7 +191,9 @@ export function DialogSessionList() {
|
|||
title: allProjects() ? "Show current directory sessions" : "Show all project sessions",
|
||||
group: "Dialog",
|
||||
run: () => {
|
||||
setAllProjects((value) => !value)
|
||||
void updatePrefs((draft) => {
|
||||
draft.allProjects = !draft.allProjects
|
||||
}).catch(() => {})
|
||||
},
|
||||
},
|
||||
]}
|
||||
|
|
|
|||
|
|
@ -87,10 +87,9 @@ export const Definitions = {
|
|||
session_move: keybind("none", "Move session"),
|
||||
session_new: keybind("<leader>n", "Create a new session"),
|
||||
session_list: keybind("<leader>l", "List all sessions"),
|
||||
open_menu: keybind("ctrl+o", "Open recent sessions and projects"),
|
||||
session_tab_next: keybind("ctrl+tab,<leader>right,alt+shift+]", "Switch to next open tab"),
|
||||
session_tab_previous: keybind("ctrl+shift+tab,<leader>left,alt+shift+[", "Switch to previous open tab"),
|
||||
session_tab_history_back: keybind("ctrl+o", "Go back in tab history"),
|
||||
session_tab_history_forward: keybind("ctrl+i", "Go forward in tab history"),
|
||||
session_tab_next_unread: keybind("<leader>down", "Switch to next unread tab"),
|
||||
session_tab_previous_unread: keybind("<leader>up", "Switch to previous unread tab"),
|
||||
session_tab_close: keybind("<leader>w", "Close current tab"),
|
||||
|
|
@ -291,10 +290,9 @@ export const CommandMap = {
|
|||
session_move: "session.move",
|
||||
session_new: "session.new",
|
||||
session_list: "session.list",
|
||||
open_menu: "open.menu",
|
||||
session_tab_next: "session.tab.next",
|
||||
session_tab_previous: "session.tab.previous",
|
||||
session_tab_history_back: "session.tab.history.back",
|
||||
session_tab_history_forward: "session.tab.history.forward",
|
||||
session_tab_next_unread: "session.tab.next_unread",
|
||||
session_tab_previous_unread: "session.tab.previous_unread",
|
||||
session_tab_close: "session.tab.close",
|
||||
|
|
|
|||
|
|
@ -296,12 +296,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
|||
)
|
||||
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
|
||||
},
|
||||
history(direction: 1 | -1) {
|
||||
if (!enabled()) return
|
||||
const next = moveSessionTabHistory(history, state().tabs, current(), direction)
|
||||
history = next.history
|
||||
if (next.sessionID) route.navigate({ type: "session", sessionID: next.sessionID })
|
||||
},
|
||||
selectIndex(index: number) {
|
||||
if (!enabled()) return
|
||||
const tab = state().tabs[index]
|
||||
|
|
|
|||
|
|
@ -110,8 +110,6 @@ test("navigates session tabs with leader arrows", () => {
|
|||
expect(config.keybinds.get("session.tab.previous")).toMatchObject([
|
||||
{ key: "ctrl+shift+tab,<leader>left,alt+shift+[" },
|
||||
])
|
||||
expect(config.keybinds.get("session.tab.history.back")).toMatchObject([{ key: "ctrl+o" }])
|
||||
expect(config.keybinds.get("session.tab.history.forward")).toMatchObject([{ key: "ctrl+i" }])
|
||||
expect(config.keybinds.get("session.tab.next_unread")).toMatchObject([{ key: "<leader>down" }])
|
||||
expect(config.keybinds.get("session.tab.previous_unread")).toMatchObject([{ key: "<leader>up" }])
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue