|
|
@@ -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),
|
|
|
}
|
|
|
},
|
|
|
})
|