diff options
| author | Daniel Polito <[email protected]> | 2026-01-16 13:51:02 -0300 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-01-16 10:51:02 -0600 |
| commit | 88fd6a294b3ad88d50cb8e1853589ee4e68dc74e (patch) | |
| tree | 1b187dab4b86528430498edc3ec3d3d4a243ea5f /packages/app/src/context | |
| parent | ea8ef37d50408203d2ef7ebbdb82bbd15bbf8461 (diff) | |
| download | opencode-88fd6a294b3ad88d50cb8e1853589ee4e68dc74e.tar.gz opencode-88fd6a294b3ad88d50cb8e1853589ee4e68dc74e.zip | |
feat(desktop): Terminal Splits (#8767)
Diffstat (limited to 'packages/app/src/context')
| -rw-r--r-- | packages/app/src/context/terminal.tsx | 390 |
1 files changed, 334 insertions, 56 deletions
diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index a7753069c..e1492c8da 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -9,12 +9,31 @@ export type LocalPTY = { id: string title: string titleNumber: number + tabId: string rows?: number cols?: number buffer?: string scrollY?: number } +export type SplitDirection = "horizontal" | "vertical" + +export type Panel = { + id: string + parentId?: string + ptyId?: string + direction?: SplitDirection + children?: [string, string] + sizes?: [number, number] +} + +export type TabPane = { + id: string + root: string + panels: Record<string, Panel> + focused?: string +} + const WORKSPACE_KEY = "__workspace__" const MAX_TERMINAL_SESSIONS = 20 @@ -25,6 +44,10 @@ type TerminalCacheEntry = { dispose: VoidFunction } +function generateId() { + return Math.random().toString(36).slice(2, 10) +} + function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, id: string | undefined) { const legacy = `${dir}/terminal${id ? "/" + id : ""}.v1` @@ -33,47 +56,102 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, id: createStore<{ active?: string all: LocalPTY[] + panes: Record<string, TabPane> }>({ all: [], + panes: {}, }), ) - return { - ready, - all: createMemo(() => Object.values(store.all)), - active: createMemo(() => store.active), - new() { - const existingTitleNumbers = new Set( - store.all.map((pty) => { - const match = pty.titleNumber - return match - }), - ) + const getNextTitleNumber = () => { + const existing = new Set(store.all.filter((p) => p.tabId === p.id).map((pty) => pty.titleNumber)) + let next = 1 + while (existing.has(next)) next++ + return next + } + + const createPty = async (tabId?: string): Promise<LocalPTY | undefined> => { + const tab = tabId ? store.all.find((p) => p.id === tabId) : undefined + const num = tab?.titleNumber ?? getNextTitleNumber() + const title = tab?.title ?? `Terminal ${num}` + const pty = await sdk.client.pty.create({ title }).catch((e) => { + console.error("Failed to create terminal", e) + return undefined + }) + if (!pty?.data?.id) return undefined + return { + id: pty.data.id, + title, + titleNumber: num, + tabId: tabId ?? pty.data.id, + } + } + + const getAllPtyIds = (pane: TabPane, panelId: string): string[] => { + const panel = pane.panels[panelId] + if (!panel) return [] + if (panel.ptyId) return [panel.ptyId] + if (panel.children && panel.children.length === 2) { + return [...getAllPtyIds(pane, panel.children[0]), ...getAllPtyIds(pane, panel.children[1])] + } + return [] + } + + const getFirstLeaf = (pane: TabPane, panelId: string): string | undefined => { + const panel = pane.panels[panelId] + if (!panel) return undefined + if (panel.ptyId) return panelId + if (panel.children?.[0]) return getFirstLeaf(pane, panel.children[0]) + return undefined + } + + const migrate = (terminals: LocalPTY[]) => + terminals.map((p) => ((p as { tabId?: string }).tabId ? p : { ...p, tabId: p.id })) - let nextNumber = 1 - while (existingTitleNumbers.has(nextNumber)) { - nextNumber++ + const tabCache = new Map<string, LocalPTY>() + const tabs = createMemo(() => { + const migrated = migrate(store.all) + const seen = new Set<string>() + const result: LocalPTY[] = [] + for (const p of migrated) { + if (!seen.has(p.tabId)) { + seen.add(p.tabId) + const cached = tabCache.get(p.tabId) + if (cached) { + cached.title = p.title + cached.titleNumber = p.titleNumber + result.push(cached) + } else { + const tab = { ...p, id: p.tabId } + tabCache.set(p.tabId, tab) + result.push(tab) + } } + } + for (const key of tabCache.keys()) { + if (!seen.has(key)) tabCache.delete(key) + } + return result + }) + const all = createMemo(() => migrate(store.all)) - sdk.client.pty - .create({ title: `Terminal ${nextNumber}` }) - .then((pty) => { - const id = pty.data?.id - if (!id) return - setStore("all", [ - ...store.all, - { - id, - title: pty.data?.title ?? "Terminal", - titleNumber: nextNumber, - }, - ]) - setStore("active", id) - }) - .catch((e) => { - console.error("Failed to create terminal", e) - }) + return { + ready, + tabs, + all, + active: () => store.active, + panes: () => store.panes, + pane: (tabId: string) => store.panes[tabId], + panel: (tabId: string, panelId: string) => store.panes[tabId]?.panels[panelId], + focused: (tabId: string) => store.panes[tabId]?.focused, + + async new() { + const pty = await createPty() + if (!pty) return + setStore("all", [...store.all, pty]) + setStore("active", pty.tabId) }, + update(pty: Partial<LocalPTY> & { id: string }) { setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x))) sdk.client.pty @@ -86,46 +164,82 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, id: console.error("Failed to update terminal", e) }) }, + async clone(id: string) { const index = store.all.findIndex((x) => x.id === id) const pty = store.all[index] if (!pty) return - const clone = await sdk.client.pty - .create({ - title: pty.title, - }) - .catch((e) => { - console.error("Failed to clone terminal", e) - return undefined - }) - if (!clone?.data) return - setStore("all", index, { - ...pty, - ...clone.data, + const clone = await sdk.client.pty.create({ title: pty.title }).catch((e) => { + console.error("Failed to clone terminal", e) + return undefined }) - if (store.active === pty.id) { - setStore("active", clone.data.id) + if (!clone?.data) return + setStore("all", index, { ...pty, ...clone.data }) + if (store.active === pty.tabId) { + setStore("active", pty.tabId) } }, + open(id: string) { setStore("active", id) }, + async close(id: string) { - batch(() => { - setStore( - "all", - store.all.filter((x) => x.id !== id), - ) - if (store.active === id) { - const index = store.all.findIndex((f) => f.id === id) - const previous = store.all[Math.max(0, index - 1)] - setStore("active", previous?.id) + const pty = store.all.find((x) => x.id === id) + if (!pty) return + + const pane = store.panes[pty.tabId] + if (pane) { + const panelId = Object.keys(pane.panels).find((key) => pane.panels[key].ptyId === id) + if (panelId) { + await this.closeSplit(pty.tabId, panelId) + return } - }) + } + + if (store.active === pty.tabId) { + const remaining = store.all.filter((p) => p.tabId === p.id && p.id !== id) + setStore("active", remaining[0]?.tabId) + } + + setStore( + "all", + store.all.filter((x) => x.id !== id), + ) + await sdk.client.pty.remove({ ptyID: id }).catch((e) => { console.error("Failed to close terminal", e) }) }, + + async closeTab(tabId: string) { + const pane = store.panes[tabId] + const terminalsInTab = store.all.filter((p) => p.tabId === tabId) + const ptyIds = pane ? getAllPtyIds(pane, pane.root) : terminalsInTab.map((p) => p.id) + + const remainingTabs = store.all.filter((p) => p.tabId !== tabId) + const uniqueTabIds = [...new Set(remainingTabs.map((p) => p.tabId))] + + setStore( + "all", + store.all.filter((x) => !ptyIds.includes(x.id)), + ) + setStore( + "panes", + produce((panes) => { + delete panes[tabId] + }), + ) + if (store.active === tabId) { + setStore("active", uniqueTabIds[0]) + } + for (const ptyId of ptyIds) { + await sdk.client.pty.remove({ ptyID: ptyId }).catch((e) => { + console.error("Failed to close terminal", e) + }) + } + }, + move(id: string, to: number) { const index = store.all.findIndex((f) => f.id === id) if (index === -1) return @@ -136,6 +250,159 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, id: }), ) }, + + async split(tabId: string, direction: SplitDirection) { + const pane = store.panes[tabId] + const newPty = await createPty(tabId) + if (!newPty) return + + setStore("all", [...store.all, newPty]) + + if (!pane) { + const rootId = generateId() + const leftId = generateId() + const rightId = generateId() + + setStore("panes", tabId, { + id: tabId, + root: rootId, + panels: { + [rootId]: { + id: rootId, + direction, + children: [leftId, rightId], + sizes: [50, 50], + }, + [leftId]: { + id: leftId, + parentId: rootId, + ptyId: tabId, + }, + [rightId]: { + id: rightId, + parentId: rootId, + ptyId: newPty.id, + }, + }, + focused: rightId, + }) + } else { + const focusedPanelId = pane.focused + if (!focusedPanelId) return + + const focusedPanel = pane.panels[focusedPanelId] + if (!focusedPanel?.ptyId) return + + const oldPtyId = focusedPanel.ptyId + const newSplitId = generateId() + const newTerminalId = generateId() + + setStore("panes", tabId, "panels", newSplitId, { + id: newSplitId, + parentId: focusedPanelId, + ptyId: oldPtyId, + }) + setStore("panes", tabId, "panels", newTerminalId, { + id: newTerminalId, + parentId: focusedPanelId, + ptyId: newPty.id, + }) + setStore("panes", tabId, "panels", focusedPanelId, "ptyId", undefined) + setStore("panes", tabId, "panels", focusedPanelId, "direction", direction) + setStore("panes", tabId, "panels", focusedPanelId, "children", [newSplitId, newTerminalId]) + setStore("panes", tabId, "panels", focusedPanelId, "sizes", [50, 50]) + setStore("panes", tabId, "focused", newTerminalId) + } + }, + + focus(tabId: string, panelId: string) { + if (store.panes[tabId]) { + setStore("panes", tabId, "focused", panelId) + } + }, + + async closeSplit(tabId: string, panelId: string) { + const pane = store.panes[tabId] + if (!pane) return + + const panel = pane.panels[panelId] + if (!panel) return + + const ptyId = panel.ptyId + if (!ptyId) return + + if (!panel.parentId) { + await this.closeTab(tabId) + return + } + + const parentPanel = pane.panels[panel.parentId] + if (!parentPanel?.children || parentPanel.children.length !== 2) return + + const siblingId = parentPanel.children[0] === panelId ? parentPanel.children[1] : parentPanel.children[0] + const sibling = pane.panels[siblingId] + if (!sibling) return + + const newFocused = sibling.ptyId ? panel.parentId! : (getFirstLeaf(pane, sibling.children![0]) ?? panel.parentId!) + + batch(() => { + setStore( + "panes", + tabId, + "panels", + produce((panels) => { + const parent = panels[panel.parentId!] + if (!parent) return + + if (sibling.ptyId) { + parent.ptyId = sibling.ptyId + parent.direction = undefined + parent.children = undefined + parent.sizes = undefined + } else if (sibling.children && sibling.children.length === 2) { + parent.ptyId = undefined + parent.direction = sibling.direction + parent.children = sibling.children + parent.sizes = sibling.sizes + panels[sibling.children[0]].parentId = panel.parentId! + panels[sibling.children[1]].parentId = panel.parentId! + } + + delete panels[panelId] + delete panels[siblingId] + }), + ) + + setStore("panes", tabId, "focused", newFocused) + + setStore( + "all", + store.all.filter((x) => x.id !== ptyId), + ) + }) + + const remainingPanels = Object.values(store.panes[tabId]?.panels ?? {}) + const shouldCleanupPane = remainingPanels.length === 1 && remainingPanels[0]?.ptyId + + if (shouldCleanupPane) { + setStore( + "panes", + produce((panes) => { + delete panes[tabId] + }), + ) + } + + await sdk.client.pty.remove({ ptyID: ptyId }).catch((e) => { + console.error("Failed to close terminal", e) + }) + }, + + resizeSplit(tabId: string, panelId: string, sizes: [number, number]) { + if (store.panes[tabId]?.panels[panelId]) { + setStore("panes", tabId, "panels", panelId, "sizes", sizes) + } + }, } } @@ -189,14 +456,25 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont return { ready: () => session().ready(), + tabs: () => session().tabs(), all: () => session().all(), active: () => session().active(), + panes: () => session().panes(), + pane: (tabId: string) => session().pane(tabId), + panel: (tabId: string, panelId: string) => session().panel(tabId, panelId), + focused: (tabId: string) => session().focused(tabId), new: () => session().new(), update: (pty: Partial<LocalPTY> & { id: string }) => session().update(pty), clone: (id: string) => session().clone(id), open: (id: string) => session().open(id), close: (id: string) => session().close(id), + closeTab: (tabId: string) => session().closeTab(tabId), move: (id: string, to: number) => session().move(id, to), + split: (tabId: string, direction: SplitDirection) => session().split(tabId, direction), + focus: (tabId: string, panelId: string) => session().focus(tabId, panelId), + closeSplit: (tabId: string, panelId: string) => session().closeSplit(tabId, panelId), + resizeSplit: (tabId: string, panelId: string, sizes: [number, number]) => + session().resizeSplit(tabId, panelId, sizes), } }, }) |
