feat(desktop): Terminal Splits (#8767)
This commit is contained in:
@@ -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: {},
|
||||
}),
|
||||
)
|
||||
|
||||
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 }))
|
||||
|
||||
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))
|
||||
|
||||
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
|
||||
}),
|
||||
)
|
||||
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,
|
||||
|
||||
let nextNumber = 1
|
||||
while (existingTitleNumbers.has(nextNumber)) {
|
||||
nextNumber++
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
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),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user