mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-04 16:06:23 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dea9125004 |
+19
-16
@@ -100,6 +100,7 @@ import { cliErrorMessage, errorFormat } from "./util/error"
|
||||
import { AttentionProvider } from "./context/attention"
|
||||
import { StorageProvider, useStorage } from "./context/storage"
|
||||
import { SessionTerminalsProvider } from "./context/session-terminals"
|
||||
import { SessionPanelProvider } from "./context/session-panel"
|
||||
import { SessionFrame } from "./component/session-frame"
|
||||
import { createTuiClipboard } from "./clipboard"
|
||||
|
||||
@@ -397,22 +398,24 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<AttentionProvider>
|
||||
<PluginProvider
|
||||
packages={input.packages}
|
||||
directories={pluginDirectories}
|
||||
>
|
||||
<App
|
||||
updater={input.updater}
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
: {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</PluginProvider>
|
||||
<SessionPanelProvider>
|
||||
<PluginProvider
|
||||
packages={input.packages}
|
||||
directories={pluginDirectories}
|
||||
>
|
||||
<App
|
||||
updater={input.updater}
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
: {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</PluginProvider>
|
||||
</SessionPanelProvider>
|
||||
</AttentionProvider>
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
import { RGBA, MouseEvent, type ScrollBoxRenderable } from "@opentui/core"
|
||||
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { batch, createEffect, createMemo, createResource, createSignal, on, Show } from "solid-js"
|
||||
import {
|
||||
batch,
|
||||
createComponent,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createSignal,
|
||||
on,
|
||||
onCleanup,
|
||||
Show,
|
||||
} from "solid-js"
|
||||
import { useConfig } from "../config"
|
||||
import { useData } from "../context/data"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useSessionTerminals } from "../context/session-terminals"
|
||||
import { usePromptRef } from "../context/prompt"
|
||||
import { useSessionPanel } from "../context/session-panel"
|
||||
import { useStorage } from "../context/storage"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { Session } from "../routes/session"
|
||||
import { Sidebar } from "../routes/session/sidebar"
|
||||
import { clampTerminalPaneWidth, SESSION_SIDEBAR_WIDTH } from "../ui/layout"
|
||||
import { clampSessionPaneWidth, SESSION_SIDEBAR_WIDTH } from "../ui/layout"
|
||||
import { createPaneResize } from "../ui/pane-resize"
|
||||
import { PaneResizeHandle } from "../ui/pane-resize-handle"
|
||||
import { useToast } from "../ui/toast"
|
||||
@@ -23,38 +35,44 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
const toast = useToast()
|
||||
const renderer = useRenderer()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const panel = useSessionPanel()
|
||||
const elevated = useTheme("elevated")
|
||||
const availableWidth = () => Math.max(0, dimensions().width - props.verticalTabsWidth)
|
||||
const defaultTerminalWidth = () => Math.max(1, Math.floor(dimensions().width / 2))
|
||||
const [layout, updateLayout] = useStorage().store<{ terminalWidth?: number }>("layout", { initial: {} })
|
||||
const terminalResize = createPaneResize({
|
||||
value: () => layout.terminalWidth ?? defaultTerminalWidth(),
|
||||
defaultValue: defaultTerminalWidth,
|
||||
clamp: (width) => clampTerminalPaneWidth(width, availableWidth()),
|
||||
const defaultPaneWidth = () => Math.max(1, Math.floor(dimensions().width / 2))
|
||||
const [layout, updateLayout] = useStorage().store<{ paneWidth?: number; terminalWidth?: number }>("layout", {
|
||||
initial: {},
|
||||
})
|
||||
const paneResize = createPaneResize({
|
||||
value: () => layout.paneWidth ?? layout.terminalWidth ?? defaultPaneWidth(),
|
||||
defaultValue: defaultPaneWidth,
|
||||
clamp: (width) => clampSessionPaneWidth(width, availableWidth()),
|
||||
fromMouse: (event) => dimensions().width - event.x - 1,
|
||||
contains: (event, width) => event.x >= dimensions().width - width - 1 && event.x <= dimensions().width - width,
|
||||
onCommit: (width) => {
|
||||
void updateLayout((draft) => {
|
||||
draft.terminalWidth = width
|
||||
draft.paneWidth = width
|
||||
}).catch((error) => console.error("Failed to persist TUI layout", error))
|
||||
},
|
||||
})
|
||||
let resizeRelease = false
|
||||
const finishTerminalResize = (event: MouseEvent) => {
|
||||
if (terminalResize.resizing()) {
|
||||
const finishPaneResize = (event: MouseEvent) => {
|
||||
if (paneResize.resizing()) {
|
||||
// A captured drag-end can be followed by mouse-up on the focus overlay.
|
||||
resizeRelease = true
|
||||
queueMicrotask(() => {
|
||||
resizeRelease = false
|
||||
})
|
||||
}
|
||||
terminalResize.onMouseUp(event)
|
||||
paneResize.onMouseUp(event)
|
||||
}
|
||||
const [sidebarOpen, setSidebarOpen] = createSignal(false)
|
||||
const [sessionWidth, setSessionWidth] = createSignal<number>()
|
||||
const [terminalFocused, setTerminalFocused] = createSignal(false)
|
||||
const [panelFocused, setPanelFocused] = createSignal(false)
|
||||
const [restoreTerminalFocus, setRestoreTerminalFocus] = createSignal(false)
|
||||
let focusTerminal: (() => void) | undefined
|
||||
let sessionScroll: ScrollBoxRenderable | undefined
|
||||
let focusPanel: (() => void) | undefined
|
||||
createResource(
|
||||
() => (config.data.session.terminal ? props.sessionID : undefined),
|
||||
(sessionID) => sessions.refresh(sessionID).catch(() => undefined),
|
||||
@@ -65,22 +83,43 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
const value = session()
|
||||
return value.terminals.find((terminal) => terminal.id === value.selectedTerminalID)
|
||||
}
|
||||
const activePanel = createMemo(() => {
|
||||
const current = panel.current()
|
||||
if (current?.sessionID === props.sessionID) return current
|
||||
})
|
||||
createEffect(
|
||||
on(
|
||||
() => selectedTerminal()?.id,
|
||||
(id) => {
|
||||
if (id) setSidebarOpen(false)
|
||||
if (!id) return
|
||||
setSidebarOpen(false)
|
||||
if (activePanel()) panel.close()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
const splitAvailable = createMemo(() => dimensions().width > 80)
|
||||
const wide = createMemo(() => dimensions().width - props.verticalTabsWidth > 120)
|
||||
createEffect(() => panel.setAvailable(props.sessionID, splitAvailable()))
|
||||
onCleanup(() => panel.setAvailable(props.sessionID, false))
|
||||
createEffect(() => {
|
||||
const current = activePanel()
|
||||
if (!current || splitAvailable()) return
|
||||
panel.close()
|
||||
current.onUnavailable?.()
|
||||
})
|
||||
createEffect(() => {
|
||||
if (!activePanel()) return
|
||||
setSidebarOpen(false)
|
||||
if (selectedTerminal()) void sessions.selectTerminal(props.sessionID, null).catch(toast.error)
|
||||
})
|
||||
const sidebarVisible = createMemo(() => {
|
||||
if (data.session.get(props.sessionID)?.parentID) return false
|
||||
if (sidebarOpen()) return true
|
||||
return (config.data.session?.sidebar ?? "auto") === "auto" && wide()
|
||||
})
|
||||
const rightPane = createMemo(() => {
|
||||
if (activePanel()) return "panel"
|
||||
if (sidebarOpen() && sidebarVisible()) return "sidebar"
|
||||
if (selectedTerminal()) return "terminal"
|
||||
if (sidebarVisible()) return "sidebar"
|
||||
@@ -94,21 +133,44 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
})
|
||||
.catch(toast.error)
|
||||
setSidebarOpen(!visible)
|
||||
if (!visible && activePanel()) panel.close()
|
||||
if (!visible && selectedTerminal()) void sessions.selectTerminal(props.sessionID, null).catch(toast.error)
|
||||
})
|
||||
}
|
||||
const focusSession = () => {
|
||||
// Permission prompts replace the input, so returning focus must not depend on it.
|
||||
if (terminalFocused()) renderer.currentFocusedRenderable?.blur()
|
||||
if (terminalFocused() || panelFocused()) renderer.currentFocusedRenderable?.blur()
|
||||
prompt.current?.focus()
|
||||
}
|
||||
const focusRightPane = () => {
|
||||
if (activePanel()) {
|
||||
focusPanel?.()
|
||||
return
|
||||
}
|
||||
focusTerminal?.()
|
||||
}
|
||||
createEffect(
|
||||
on(
|
||||
() => activePanel()?.id,
|
||||
(id) => {
|
||||
if (!id) {
|
||||
setPanelFocused(false)
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
if (activePanel()?.id !== id) return
|
||||
focusPanel?.()
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
createEffect(() => {
|
||||
if (!restoreTerminalFocus() || selectedTerminal()) return
|
||||
setRestoreTerminalFocus(false)
|
||||
focusSession()
|
||||
})
|
||||
Keymap.createLayer(() => ({
|
||||
enabled: () => config.data.session.terminal === true,
|
||||
enabled: () => config.data.session.terminal === true || activePanel() !== undefined,
|
||||
commands: [
|
||||
{
|
||||
id: "pane.focus.left",
|
||||
@@ -117,10 +179,8 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
},
|
||||
{
|
||||
id: "pane.focus.right",
|
||||
title: "Focus terminal pane",
|
||||
run: () => {
|
||||
focusTerminal?.()
|
||||
},
|
||||
title: "Focus right pane",
|
||||
run: focusRightPane,
|
||||
},
|
||||
],
|
||||
}))
|
||||
@@ -132,9 +192,9 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
minHeight={0}
|
||||
flexDirection="row"
|
||||
position="relative"
|
||||
onMouseDrag={terminalResize.onMouseDrag}
|
||||
onMouseDragEnd={finishTerminalResize}
|
||||
onMouseUp={finishTerminalResize}
|
||||
onMouseDrag={paneResize.onMouseDrag}
|
||||
onMouseDragEnd={finishPaneResize}
|
||||
onMouseUp={finishPaneResize}
|
||||
>
|
||||
<box
|
||||
flexGrow={1}
|
||||
@@ -149,13 +209,13 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
<Session
|
||||
scrollRef={(value) => (sessionScroll = value)}
|
||||
verticalTabsWidth={props.verticalTabsWidth}
|
||||
promptMuted={terminalFocused()}
|
||||
promptMuted={terminalFocused() || panelFocused()}
|
||||
sidebarVisible={rightPane() === "sidebar"}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
visibleTerminalID={rightPane() === "terminal" ? selectedTerminal()?.id : undefined}
|
||||
width={sessionWidth()}
|
||||
/>
|
||||
<Show when={terminalFocused()}>
|
||||
<Show when={terminalFocused() || panelFocused()}>
|
||||
<box
|
||||
position="absolute"
|
||||
left={0}
|
||||
@@ -174,37 +234,61 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
}}
|
||||
// Consume the release before revealing permission buttons underneath.
|
||||
onMouseUp={() => {
|
||||
if (terminalResize.resizing() || resizeRelease) return
|
||||
if (paneResize.resizing() || resizeRelease) return
|
||||
focusSession()
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={rightPane() === "terminal" || (rightPane() === "sidebar" && wide())}>
|
||||
<Show when={rightPane() === "terminal" || rightPane() === "panel" || (rightPane() === "sidebar" && wide())}>
|
||||
<box
|
||||
flexShrink={0}
|
||||
width={rightPane() === "terminal" ? terminalResize.size() : SESSION_SIDEBAR_WIDTH}
|
||||
width={rightPane() === "terminal" || rightPane() === "panel" ? paneResize.size() : SESSION_SIDEBAR_WIDTH}
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
backgroundColor={rightPane() === "panel" ? elevated.background.default : undefined}
|
||||
>
|
||||
<Show
|
||||
when={rightPane() === "sidebar"}
|
||||
fallback={
|
||||
<Show keyed when={selectedTerminal()?.id}>
|
||||
{(ptyID) => (
|
||||
<TerminalPane
|
||||
ptyID={ptyID}
|
||||
resizing={terminalResize.resizing()}
|
||||
autoFocus={restoreTerminalFocus() || sessions.shouldFocus(ptyID)}
|
||||
onAutoFocus={() => {
|
||||
sessions.clearFocus(ptyID)
|
||||
setRestoreTerminalFocus(false)
|
||||
}}
|
||||
onFocusChange={setTerminalFocused}
|
||||
onFocusRequest={(value) => (focusTerminal = value)}
|
||||
onDisconnect={() => setRestoreTerminalFocus(true)}
|
||||
/>
|
||||
)}
|
||||
<Show
|
||||
keyed
|
||||
when={activePanel()}
|
||||
fallback={
|
||||
<Show keyed when={selectedTerminal()?.id}>
|
||||
{(ptyID) => (
|
||||
<TerminalPane
|
||||
ptyID={ptyID}
|
||||
resizing={paneResize.resizing()}
|
||||
autoFocus={restoreTerminalFocus() || sessions.shouldFocus(ptyID)}
|
||||
onAutoFocus={() => {
|
||||
sessions.clearFocus(ptyID)
|
||||
setRestoreTerminalFocus(false)
|
||||
}}
|
||||
onFocusChange={setTerminalFocused}
|
||||
onFocusRequest={(value) => (focusTerminal = value)}
|
||||
onDisconnect={() => setRestoreTerminalFocus(true)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(item) =>
|
||||
createComponent(item.render, {
|
||||
get width() {
|
||||
return paneResize.size()
|
||||
},
|
||||
get resizing() {
|
||||
return paneResize.resizing()
|
||||
},
|
||||
get focused() {
|
||||
return panelFocused()
|
||||
},
|
||||
onFocusChange: setPanelFocused,
|
||||
onFocusRequest: (value) => (focusPanel = value),
|
||||
close: panel.close,
|
||||
})
|
||||
}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
@@ -212,12 +296,8 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={rightPane() === "terminal" && availableWidth() >= 3}>
|
||||
<PaneResizeHandle
|
||||
resize={terminalResize}
|
||||
left={availableWidth() - terminalResize.size() - 1}
|
||||
highlight="right"
|
||||
/>
|
||||
<Show when={(rightPane() === "terminal" || rightPane() === "panel") && availableWidth() >= 3}>
|
||||
<PaneResizeHandle resize={paneResize} left={availableWidth() - paneResize.size() - 1} highlight="right" />
|
||||
</Show>
|
||||
<Show when={rightPane() === "sidebar" && !wide()}>
|
||||
<box
|
||||
|
||||
@@ -84,6 +84,7 @@ export const Definitions = {
|
||||
"diff.single_patch": keybind("s", "Toggle single patch view"),
|
||||
"diff.switch_source": keybind("d", "Switch diff viewer source"),
|
||||
"diff.toggle_view": keybind("v", "Toggle diff viewer split or unified view"),
|
||||
"diff.toggle_fullscreen": keybind("f", "Toggle diff viewer full screen"),
|
||||
"diff.mark_reviewed": keybind("m", "Toggle selected diff file reviewed"),
|
||||
"diff.help": keybind("?,shift+?,shift+/", "Show more diff viewer shortcuts"),
|
||||
|
||||
@@ -93,7 +94,7 @@ export const Definitions = {
|
||||
"theme.mode.lock": keybind("none", "Lock or unlock theme mode"),
|
||||
"session.sidebar.toggle": keybind("<leader>b", "Toggle sidebar"),
|
||||
"pane.focus.left": keybind("<leader>left", "Focus session pane"),
|
||||
"pane.focus.right": keybind("<leader>right", "Focus terminal pane"),
|
||||
"pane.focus.right": keybind("<leader>right", "Focus right pane"),
|
||||
"terminal.select": keybind("<leader>down", "Select terminal"),
|
||||
"terminal.toggle": keybind("<leader>t", "Toggle terminal pane"),
|
||||
"terminal.close": keybind("<leader>up", "Close terminal pane"),
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { createContext, createSignal, useContext, type JSX, type ParentProps } from "solid-js"
|
||||
|
||||
export type SessionPanelRenderProps = {
|
||||
readonly width: number
|
||||
readonly resizing: boolean
|
||||
readonly focused: boolean
|
||||
readonly onFocusChange: (focused: boolean) => void
|
||||
readonly onFocusRequest: (focus: (() => void) | undefined) => void
|
||||
readonly close: () => void
|
||||
}
|
||||
|
||||
type Panel = {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly render: (props: SessionPanelRenderProps) => JSX.Element
|
||||
readonly onUnavailable?: () => void
|
||||
}
|
||||
|
||||
const Context = createContext<{
|
||||
readonly current: () => Panel | undefined
|
||||
readonly open: (panel: Panel) => void
|
||||
readonly close: () => void
|
||||
readonly available: (sessionID: string) => boolean
|
||||
readonly setAvailable: (sessionID: string, available: boolean) => void
|
||||
}>()
|
||||
|
||||
export function SessionPanelProvider(props: ParentProps) {
|
||||
const [current, setCurrent] = createSignal<Panel>()
|
||||
const [availableSessionID, setAvailableSessionID] = createSignal<string>()
|
||||
return (
|
||||
<Context.Provider
|
||||
value={{
|
||||
current,
|
||||
open: setCurrent,
|
||||
close: () => setCurrent(),
|
||||
available: (sessionID) => availableSessionID() === sessionID,
|
||||
setAvailable: (sessionID, available) =>
|
||||
setAvailableSessionID((current) => (available ? sessionID : current === sessionID ? undefined : current)),
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</Context.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useSessionPanel() {
|
||||
const value = useContext(Context)
|
||||
if (!value) throw new Error("useSessionPanel must be used within a SessionPanelProvider")
|
||||
return value
|
||||
}
|
||||
|
||||
export function useOptionalSessionPanel() {
|
||||
return useContext(Context)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { Vcs } from "@opencode-ai/schema/vcs"
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import type { KeymapCommand, Route } from "@opencode-ai/plugin/tui/context"
|
||||
import {
|
||||
CliRenderEvents,
|
||||
MouseButton,
|
||||
TextAttributes,
|
||||
type BoxRenderable,
|
||||
@@ -22,7 +23,8 @@ import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { createDebouncedSignal } from "../../util/signal"
|
||||
import { useConfig } from "../../config"
|
||||
import { locationKey } from "../../context/data"
|
||||
import { useThemes } from "../../context/theme"
|
||||
import { useOptionalSessionPanel } from "../../context/session-panel"
|
||||
import { useTheme, useThemes } from "../../context/theme"
|
||||
import { PatchDiff, type PatchDiffRef } from "../../component/patch-diff"
|
||||
import {
|
||||
allExpandedFileTreeDirectories,
|
||||
@@ -75,9 +77,55 @@ function diffSourceLabel(mode: DiffMode) {
|
||||
return "Uncommitted"
|
||||
}
|
||||
|
||||
function DiffViewer(props: { context: Plugin.Context }) {
|
||||
type PanelController = NonNullable<ReturnType<typeof useOptionalSessionPanel>>
|
||||
|
||||
function openDiffPanel(context: Plugin.Context, panel: PanelController, sessionID: string) {
|
||||
panel.open({
|
||||
id: ROUTE,
|
||||
sessionID,
|
||||
onUnavailable: () => openDiffFullscreen(context, panel, sessionID, false),
|
||||
render: (input) => (
|
||||
<DiffViewer
|
||||
context={context}
|
||||
sessionID={sessionID}
|
||||
width={input.width}
|
||||
embedded
|
||||
focused={input.focused}
|
||||
onFocusChange={input.onFocusChange}
|
||||
onFocusRequest={input.onFocusRequest}
|
||||
onClose={input.close}
|
||||
/>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
function openDiffFullscreen(context: Plugin.Context, panel: PanelController, sessionID: string, split: boolean) {
|
||||
panel.close()
|
||||
context.ui.router.navigate({
|
||||
type: "plugin",
|
||||
name: ROUTE,
|
||||
data: {
|
||||
sessionID,
|
||||
returnRoute: { type: "session", sessionID },
|
||||
...(split ? { split: true } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function DiffViewer(props: {
|
||||
context: Plugin.Context
|
||||
sessionID?: string
|
||||
width?: number
|
||||
embedded?: boolean
|
||||
focused?: boolean
|
||||
onFocusChange?: (focused: boolean) => void
|
||||
onFocusRequest?: (focus: (() => void) | undefined) => void
|
||||
onClose?: () => void
|
||||
}) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const renderer = useRenderer()
|
||||
const config = useConfig()
|
||||
const panel = useOptionalSessionPanel()
|
||||
const [memory, updateMemory] = props.context.storage.memory<{
|
||||
source?: DiffMode
|
||||
bases: Record<string, string>
|
||||
@@ -89,13 +137,14 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
mode?: DiffMode
|
||||
sessionID?: string
|
||||
returnRoute?: Route
|
||||
split?: boolean
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
const [mode, setMode] = createSignal(params()?.mode ?? memory.source ?? config.data.diffs?.source ?? "branch")
|
||||
const location = createMemo(
|
||||
() => {
|
||||
const sessionID = params()?.sessionID
|
||||
const sessionID = props.sessionID ?? params()?.sessionID
|
||||
return sessionID
|
||||
? (props.context.data.session.get(sessionID)?.location ?? props.context.data.location.default())
|
||||
: props.context.data.location.default()
|
||||
@@ -157,52 +206,103 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
if (!base) return "Base not reported"
|
||||
return `vs ${base.name}`
|
||||
}
|
||||
const sessionID = () => props.sessionID ?? params()?.sessionID
|
||||
const canToggleFullscreen = () =>
|
||||
panel !== undefined &&
|
||||
sessionID() !== undefined &&
|
||||
(props.embedded === true || (params()?.split === true && dimensions().width > 80))
|
||||
const toggleFullscreen = () => {
|
||||
const id = sessionID()
|
||||
if (!panel || !id) return
|
||||
if (props.embedded) {
|
||||
openDiffFullscreen(props.context, panel, id, true)
|
||||
return
|
||||
}
|
||||
openDiffPanel(props.context, panel, id)
|
||||
props.context.ui.router.navigate({ type: "session", sessionID: id })
|
||||
}
|
||||
let panelNode: BoxRenderable | undefined
|
||||
const onFocused = () => props.onFocusChange?.(renderer.currentFocusedRenderable === panelNode)
|
||||
renderer.on(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)
|
||||
onCleanup(() => {
|
||||
renderer.off(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)
|
||||
props.onFocusChange?.(false)
|
||||
props.onFocusRequest?.(undefined)
|
||||
})
|
||||
|
||||
const content = () => (
|
||||
<DiffViewerContent
|
||||
context={props.context}
|
||||
files={result()?.files ?? []}
|
||||
loading={diff.loading}
|
||||
error={diff.error}
|
||||
mode={mode()}
|
||||
sourceDetail={sourceDetail()}
|
||||
sourceBase={sourceBase()}
|
||||
unavailable={mode() === "committed" && !!result() && !result()?.base}
|
||||
preferences={props.embedded ? { ...config.data.diffs, tree: false } : config.data.diffs}
|
||||
width={props.width}
|
||||
fileTree={!props.embedded}
|
||||
elevated={props.embedded}
|
||||
focused={props.embedded ? props.focused === true : true}
|
||||
onToggleFullscreen={canToggleFullscreen() ? toggleFullscreen : undefined}
|
||||
loadImage={(file, signal) => props.context.client.file.read({ path: file, location: location() }, { signal })}
|
||||
onPreferencesChange={(value) => {
|
||||
void config
|
||||
.update((draft) => {
|
||||
draft.diffs = { ...draft.diffs, ...value }
|
||||
})
|
||||
.catch(() => {})
|
||||
}}
|
||||
onClose={() => {
|
||||
if (props.onClose) return props.onClose()
|
||||
props.context.ui.router.navigate(params()?.returnRoute ?? { type: "home" })
|
||||
}}
|
||||
onSwitchSource={(mode) => {
|
||||
updateMemory((draft) => {
|
||||
draft.source = mode
|
||||
})
|
||||
setMode(mode)
|
||||
}}
|
||||
onChooseBase={() => {
|
||||
const target = { ...location() }
|
||||
const key = baseKey()
|
||||
if (!memory.bases[key]) void loadBase(target, key).catch(() => {})
|
||||
props.context.ui.dialog.show(() => (
|
||||
<DiffBaseDialog
|
||||
context={props.context}
|
||||
location={target}
|
||||
current={memory.bases[key] ?? reportedBases().get(key)?.ref}
|
||||
onSelect={(ref) =>
|
||||
updateMemory((draft) => {
|
||||
draft.bases[key] = ref
|
||||
})
|
||||
}
|
||||
/>
|
||||
))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
if (props.embedded)
|
||||
return (
|
||||
<box
|
||||
ref={(node: BoxRenderable) => {
|
||||
panelNode = node
|
||||
props.onFocusRequest?.(() => node.focus())
|
||||
}}
|
||||
flexGrow={1}
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
focusable
|
||||
onMouseDown={() => panelNode?.focus()}
|
||||
>
|
||||
{content()}
|
||||
</box>
|
||||
)
|
||||
return (
|
||||
<box position="absolute" zIndex={2500} left={0} top={0} width={dimensions().width} height={dimensions().height}>
|
||||
<DiffViewerContent
|
||||
context={props.context}
|
||||
files={result()?.files ?? []}
|
||||
loading={diff.loading}
|
||||
error={diff.error}
|
||||
mode={mode()}
|
||||
sourceDetail={sourceDetail()}
|
||||
sourceBase={sourceBase()}
|
||||
unavailable={mode() === "committed" && !!result() && !result()?.base}
|
||||
preferences={config.data.diffs}
|
||||
loadImage={(file, signal) => props.context.client.file.read({ path: file, location: location() }, { signal })}
|
||||
onPreferencesChange={(value) => {
|
||||
void config
|
||||
.update((draft) => {
|
||||
draft.diffs = { ...draft.diffs, ...value }
|
||||
})
|
||||
.catch(() => {})
|
||||
}}
|
||||
onClose={() => props.context.ui.router.navigate(params()?.returnRoute ?? { type: "home" })}
|
||||
onSwitchSource={(mode) => {
|
||||
updateMemory((draft) => {
|
||||
draft.source = mode
|
||||
})
|
||||
setMode(mode)
|
||||
}}
|
||||
onChooseBase={() => {
|
||||
const target = { ...location() }
|
||||
const key = baseKey()
|
||||
if (!memory.bases[key]) void loadBase(target, key).catch(() => {})
|
||||
props.context.ui.dialog.show(() => (
|
||||
<DiffBaseDialog
|
||||
context={props.context}
|
||||
location={target}
|
||||
current={memory.bases[key] ?? reportedBases().get(key)?.ref}
|
||||
onSelect={(ref) =>
|
||||
updateMemory((draft) => {
|
||||
draft.bases[key] = ref
|
||||
})
|
||||
}
|
||||
/>
|
||||
))
|
||||
}}
|
||||
/>
|
||||
{content()}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -266,28 +366,36 @@ export function DiffViewerContent(props: {
|
||||
navigation?: "tree" | "list"
|
||||
loadImage?: (file: string, signal: AbortSignal) => Promise<Uint8Array>
|
||||
preferences?: DiffPreferences
|
||||
width?: number
|
||||
fileTree?: boolean
|
||||
elevated?: boolean
|
||||
focused?: boolean
|
||||
onPreferencesChange?: (value: DiffPreferences) => void
|
||||
onClose: () => void
|
||||
onSwitchSource: (mode: DiffMode) => void
|
||||
onChooseBase?: () => void
|
||||
onToggleFullscreen?: () => void
|
||||
}) {
|
||||
const renderer = useRenderer()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const config = useConfig()
|
||||
const dialog = props.context.ui.dialog
|
||||
const theme = useThemes().current
|
||||
const currentSyntax = useThemes().currentSyntax
|
||||
const themes = useThemes()
|
||||
const elevated = useTheme("elevated")
|
||||
const theme = props.elevated ? elevated : themes.current
|
||||
const currentSyntax = themes.currentSyntax
|
||||
const files = () => props.files
|
||||
const width = () => props.width ?? dimensions().width
|
||||
const mode = () => props.mode
|
||||
const [fileTreeEnabled, setFileTreeEnabled] = createSignal(props.preferences?.tree ?? true)
|
||||
const showFileTree = createMemo(
|
||||
() => dimensions().width >= 90 && showDiffViewerFileTree(fileTreeEnabled(), files().length),
|
||||
() => props.fileTree !== false && width() >= 90 && showDiffViewerFileTree(fileTreeEnabled(), files().length),
|
||||
)
|
||||
const [singlePatch, setSinglePatch] = createSignal(props.preferences?.single ?? false)
|
||||
const fileTreeWidth = createMemo(() =>
|
||||
Math.max(FILE_TREE_MIN_WIDTH, Math.min(FILE_TREE_MAX_WIDTH, Math.floor(dimensions().width / 4))),
|
||||
Math.max(FILE_TREE_MIN_WIDTH, Math.min(FILE_TREE_MAX_WIDTH, Math.floor(width() / 4))),
|
||||
)
|
||||
const patchPaneWidth = createMemo(() => dimensions().width - (showFileTree() ? fileTreeWidth() : 0) - 4)
|
||||
const patchPaneWidth = createMemo(() => width() - (showFileTree() ? fileTreeWidth() : 0) - (props.elevated ? 2 : 4))
|
||||
const splitAvailable = createMemo(() => patchPaneWidth() >= MIN_SPLIT_WIDTH)
|
||||
const [viewOverride, setViewOverride] = createSignal<DiffView | undefined>(storedView(props.preferences?.view))
|
||||
const view = createMemo(() =>
|
||||
@@ -301,6 +409,7 @@ export function DiffViewerContent(props: {
|
||||
const patchScrollAcceleration = createMemo(() => getScrollAcceleration(config.data))
|
||||
const patchFileIndexes = createMemo(() => orderedPatchFileIndexes(flattenFileTree(fileTree())))
|
||||
const helpShortcut = () => props.context.keymap.shortcuts("diff.help")[0]
|
||||
const firstShortcut = (id: string, fallback: string) => props.context.keymap.shortcuts(id)[0] ?? fallback
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
const patchNodeByFileIndex = new Map<number, BoxRenderable>()
|
||||
const patchDiffByFileIndex = new Map<number, PatchDiffRef>()
|
||||
@@ -683,6 +792,13 @@ export function DiffViewerContent(props: {
|
||||
props.onPreferencesChange?.({ view: next })
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "diff.toggle_fullscreen",
|
||||
title: "Toggle diff viewer full screen",
|
||||
group: "VCS",
|
||||
enabled: () => props.onToggleFullscreen !== undefined,
|
||||
run: () => props.onToggleFullscreen?.(),
|
||||
},
|
||||
{
|
||||
id: "diff.help",
|
||||
title: "Show more diff viewer shortcuts",
|
||||
@@ -748,7 +864,13 @@ export function DiffViewerContent(props: {
|
||||
}
|
||||
|
||||
const openHelpDialog = () => {
|
||||
dialog.show(() => <DiffViewerHelpDialog context={props.context} single={singlePatch()} />)
|
||||
dialog.show(() => (
|
||||
<DiffViewerHelpDialog
|
||||
context={props.context}
|
||||
single={singlePatch()}
|
||||
fullscreen={props.onToggleFullscreen !== undefined}
|
||||
/>
|
||||
))
|
||||
dialog.set({ size: "medium", centered: true })
|
||||
}
|
||||
|
||||
@@ -777,6 +899,7 @@ export function DiffViewerContent(props: {
|
||||
)
|
||||
|
||||
props.context.keymap.layer(() => ({
|
||||
enabled: () => props.focused !== false,
|
||||
commands,
|
||||
}))
|
||||
|
||||
@@ -874,7 +997,13 @@ export function DiffViewerContent(props: {
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<box flexGrow={1} minWidth={0} minHeight={0} paddingLeft={2} paddingRight={2}>
|
||||
<box
|
||||
flexGrow={1}
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
paddingLeft={props.elevated ? 1 : 2}
|
||||
paddingRight={props.elevated ? 1 : 2}
|
||||
>
|
||||
<box
|
||||
id="diff-patch-top-edge"
|
||||
ref={(edge: BoxRenderable) => {
|
||||
@@ -953,7 +1082,7 @@ export function DiffViewerContent(props: {
|
||||
zIndex={1}
|
||||
backgroundColor={background()}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
paddingRight={props.elevated ? 0 : 1}
|
||||
paddingBottom={1}
|
||||
>
|
||||
<box flexGrow={1} minWidth={0}>
|
||||
@@ -1056,7 +1185,45 @@ export function DiffViewerContent(props: {
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
<Show when={!showFileTree()}>
|
||||
<Show when={props.elevated}>
|
||||
<box height={1} flexShrink={0} />
|
||||
<box height={1} flexShrink={0} paddingLeft={2} paddingRight={2}>
|
||||
<Show
|
||||
when={props.focused}
|
||||
fallback={
|
||||
<text fg={theme.text.subdued} flexGrow={1} minWidth={0} wrapMode="none" truncate>
|
||||
<span style={{ fg: theme.text.default }}>{firstShortcut("pane.focus.right", "ctrl+x →")}</span>
|
||||
{" focus diff"}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<text fg={theme.text.subdued} flexGrow={1} minWidth={0} wrapMode="none" truncate>
|
||||
<span style={{ fg: theme.text.default }}>{firstShortcut("pane.focus.left", "ctrl+x ←")}</span>
|
||||
{" focus session "}
|
||||
<span style={{ fg: theme.text.default }}>{firstShortcut("diff.toggle_fullscreen", "f")}</span>
|
||||
{" full screen "}
|
||||
<span style={{ fg: theme.text.default }}>
|
||||
{firstShortcut("diff.down", "j")}/{firstShortcut("diff.up", "k")}
|
||||
</span>
|
||||
{" scroll "}
|
||||
<span style={{ fg: theme.text.default }}>
|
||||
{firstShortcut("diff.next_file", "n")}/{firstShortcut("diff.previous_file", "p")}
|
||||
</span>
|
||||
{" files "}
|
||||
<span style={{ fg: theme.text.default }}>
|
||||
{firstShortcut("diff.next_hunk", "]")}/{firstShortcut("diff.previous_hunk", "[")}
|
||||
</span>
|
||||
{" hunks "}
|
||||
<span style={{ fg: theme.text.default }}>{firstShortcut("diff.close", "q")}</span>
|
||||
{" close "}
|
||||
<span style={{ fg: theme.text.default }}>{helpShortcut() ?? "?"}</span>
|
||||
{" see all"}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<box height={1} flexShrink={0} />
|
||||
</Show>
|
||||
<Show when={!showFileTree() && !props.elevated}>
|
||||
<box position="absolute" top={0} right={0} width={1} height={1}>
|
||||
<HelpShortcut compact />
|
||||
</box>
|
||||
@@ -1146,7 +1313,7 @@ function DiffFileMenu(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean }) {
|
||||
function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean; fullscreen: boolean }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = props.context.theme.contextual.elevated
|
||||
const shortcut =
|
||||
@@ -1186,6 +1353,9 @@ function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean
|
||||
{ shortcut: shortcut("diff.single_patch"), label: "All files / single file" },
|
||||
{ shortcut: shortcut("diff.toggle_file_tree"), label: "Show / hide file tree" },
|
||||
{ shortcut: shortcut("diff.switch_source"), label: "Switch diff source" },
|
||||
...(props.fullscreen
|
||||
? [{ shortcut: shortcut("diff.toggle_fullscreen"), label: "Full screen / split view" }]
|
||||
: []),
|
||||
{ shortcut: () => props.context.keymap.shortcuts("diff.close").join(" / "), label: "Close diff viewer" },
|
||||
],
|
||||
},
|
||||
@@ -1243,6 +1413,7 @@ function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean
|
||||
}
|
||||
|
||||
function Commands(props: { context: Plugin.Context }) {
|
||||
const panel = useOptionalSessionPanel()
|
||||
props.context.keymap.layer(() => ({
|
||||
mode: "global",
|
||||
commands: [
|
||||
@@ -1254,6 +1425,15 @@ function Commands(props: { context: Plugin.Context }) {
|
||||
palette: true,
|
||||
run() {
|
||||
const route = props.context.ui.router.current()
|
||||
if (route.type === "session" && panel?.available(route.sessionID)) {
|
||||
if (panel.current()?.id === ROUTE && panel.current()?.sessionID === route.sessionID) {
|
||||
panel.close()
|
||||
} else {
|
||||
openDiffPanel(props.context, panel, route.sessionID)
|
||||
}
|
||||
props.context.ui.dialog.clear()
|
||||
return
|
||||
}
|
||||
const returnRoute: Route =
|
||||
route.type === "home"
|
||||
? { type: "home" }
|
||||
|
||||
@@ -1507,7 +1507,7 @@ export function Session(props: {
|
||||
<Prompt
|
||||
visible={true}
|
||||
ref={bind}
|
||||
disabled={false}
|
||||
disabled={props.promptMuted}
|
||||
muted={props.promptMuted}
|
||||
onSubmit={() => {
|
||||
toBottom()
|
||||
|
||||
@@ -14,7 +14,7 @@ export function clampSessionTabsWidth(width: number, total: number) {
|
||||
)
|
||||
}
|
||||
|
||||
export function clampTerminalPaneWidth(width: number, total: number) {
|
||||
export function clampSessionPaneWidth(width: number, total: number) {
|
||||
const half = Math.max(1, Math.floor(total / 2))
|
||||
// Preserve the equal split when there is not enough room for both pane minima.
|
||||
return Math.max(Math.min(24, half), Math.min(width, Math.max(half, total - SESSION_CONTENT_MIN_WIDTH)))
|
||||
|
||||
@@ -37,6 +37,7 @@ import { createApi, createEventStream, createFetch, json } from "../../fixture/t
|
||||
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
|
||||
import { createDialogApi } from "../../../src/plugin/api"
|
||||
import { ToastProvider } from "../../../src/ui/toast"
|
||||
import { SessionPanelProvider } from "../../../src/context/session-panel"
|
||||
import { createSignal, Show } from "solid-js"
|
||||
import { diffImageFixture } from "../../fixture/diff-image"
|
||||
|
||||
@@ -69,6 +70,43 @@ test("closing the diff viewer returns to the route it opened from", async () =>
|
||||
}
|
||||
})
|
||||
|
||||
test("full-screen diff only returns to split view when opened from an eligible panel", async () => {
|
||||
const narrow = await renderDiffViewer([], {
|
||||
width: 80,
|
||||
initialRoute: {
|
||||
type: "plugin",
|
||||
id: "opencode.diffs",
|
||||
name: "diff",
|
||||
data: { sessionID: "session-1", returnRoute: startRoute },
|
||||
},
|
||||
})
|
||||
try {
|
||||
const command = narrow.commands.get("diff.toggle_fullscreen")
|
||||
expect(typeof command?.enabled === "function" ? command.enabled() : command?.enabled).toBe(false)
|
||||
} finally {
|
||||
narrow.app.renderer.destroy()
|
||||
}
|
||||
|
||||
const eligible = await renderDiffViewer([], {
|
||||
width: 160,
|
||||
initialRoute: {
|
||||
type: "plugin",
|
||||
id: "opencode.diffs",
|
||||
name: "diff",
|
||||
data: { sessionID: "session-1", returnRoute: startRoute, split: true },
|
||||
},
|
||||
})
|
||||
try {
|
||||
const command = eligible.commands.get("diff.toggle_fullscreen")
|
||||
expect(typeof command?.enabled === "function" ? command.enabled() : command?.enabled).toBe(true)
|
||||
command?.run()
|
||||
await eligible.app.flush()
|
||||
expect(eligible.current()).toEqual(startRoute)
|
||||
} finally {
|
||||
eligible.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("ctrl+c closes the diff viewer without exiting the application", async () => {
|
||||
const viewer = await renderDiffViewer([])
|
||||
|
||||
@@ -1972,7 +2010,9 @@ async function renderDiffViewer(
|
||||
<ToastProvider>
|
||||
<ThemeProvider mode={options.mode ?? "dark"} source={emptyThemeSource}>
|
||||
<DialogProvider>
|
||||
<Content />
|
||||
<SessionPanelProvider>
|
||||
<Content />
|
||||
</SessionPanelProvider>
|
||||
</DialogProvider>
|
||||
</ThemeProvider>
|
||||
</ToastProvider>
|
||||
|
||||
@@ -209,6 +209,7 @@ test("centralizes named command defaults and resolves explicit none", () => {
|
||||
"diff.next_hunk": "]",
|
||||
"diff.previous_hunk": "[",
|
||||
"diff.mark_reviewed": "m",
|
||||
"diff.toggle_fullscreen": "f",
|
||||
"diff.help": "?,shift+?,shift+/",
|
||||
}
|
||||
const config = resolve({}, { terminalSuspend: true })
|
||||
|
||||
Reference in New Issue
Block a user