summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/context
diff options
context:
space:
mode:
Diffstat (limited to 'packages/app/src/context')
-rw-r--r--packages/app/src/context/terminal.tsx390
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),
}
},
})