diff options
| author | Aiden Cline <[email protected]> | 2025-12-04 15:57:01 -0600 |
|---|---|---|
| committer | Aiden Cline <[email protected]> | 2025-12-04 15:57:01 -0600 |
| commit | f9dcd979364acc5172fd0044c1c8b04dcaec9229 (patch) | |
| tree | 99e6ada5f45288cc72c558b8ce52e34e8fb0659a /packages/desktop/src | |
| parent | d763c11a6d5bc57869f11c87f5a293f61e427e0a (diff) | |
| download | opencode-f9dcd979364acc5172fd0044c1c8b04dcaec9229.tar.gz opencode-f9dcd979364acc5172fd0044c1c8b04dcaec9229.zip | |
Revert "feat(desktop): terminal pane (#5081)"
This reverts commit d763c11a6d5bc57869f11c87f5a293f61e427e0a.
Diffstat (limited to 'packages/desktop/src')
| -rw-r--r-- | packages/desktop/src/addons/serialize.ts | 649 | ||||
| -rw-r--r-- | packages/desktop/src/components/terminal.tsx | 151 | ||||
| -rw-r--r-- | packages/desktop/src/context/layout.tsx | 22 | ||||
| -rw-r--r-- | packages/desktop/src/context/sdk.tsx | 2 | ||||
| -rw-r--r-- | packages/desktop/src/context/session.tsx | 100 | ||||
| -rw-r--r-- | packages/desktop/src/pages/layout.tsx | 114 | ||||
| -rw-r--r-- | packages/desktop/src/pages/session.tsx | 649 |
7 files changed, 294 insertions, 1393 deletions
diff --git a/packages/desktop/src/addons/serialize.ts b/packages/desktop/src/addons/serialize.ts deleted file mode 100644 index 03899ff10..000000000 --- a/packages/desktop/src/addons/serialize.ts +++ /dev/null @@ -1,649 +0,0 @@ -/** - * SerializeAddon - Serialize terminal buffer contents - * - * Port of xterm.js addon-serialize for ghostty-web. - * Enables serialization of terminal contents to a string that can - * be written back to restore terminal state. - * - * Usage: - * ```typescript - * const serializeAddon = new SerializeAddon(); - * term.loadAddon(serializeAddon); - * const content = serializeAddon.serialize(); - * ``` - */ - -import type { ITerminalAddon, ITerminalCore, IBufferRange } from "ghostty-web" - -// ============================================================================ -// Buffer Types (matching ghostty-web internal interfaces) -// ============================================================================ - -interface IBuffer { - readonly type: "normal" | "alternate" - readonly cursorX: number - readonly cursorY: number - readonly viewportY: number - readonly baseY: number - readonly length: number - getLine(y: number): IBufferLine | undefined - getNullCell(): IBufferCell -} - -interface IBufferLine { - readonly length: number - readonly isWrapped: boolean - getCell(x: number): IBufferCell | undefined - translateToString(trimRight?: boolean, startColumn?: number, endColumn?: number): string -} - -interface IBufferCell { - getChars(): string - getCode(): number - getWidth(): number - getFgColorMode(): number - getBgColorMode(): number - getFgColor(): number - getBgColor(): number - isBold(): number - isItalic(): number - isUnderline(): number - isStrikethrough(): number - isBlink(): number - isInverse(): number - isInvisible(): number - isFaint(): number - isDim(): boolean -} - -// ============================================================================ -// Types -// ============================================================================ - -export interface ISerializeOptions { - /** - * The row range to serialize. When an explicit range is specified, the cursor - * will get its final repositioning. - */ - range?: ISerializeRange - /** - * The number of rows in the scrollback buffer to serialize, starting from - * the bottom of the scrollback buffer. When not specified, all available - * rows in the scrollback buffer will be serialized. - */ - scrollback?: number - /** - * Whether to exclude the terminal modes from the serialization. - * Default: false - */ - excludeModes?: boolean - /** - * Whether to exclude the alt buffer from the serialization. - * Default: false - */ - excludeAltBuffer?: boolean -} - -export interface ISerializeRange { - /** - * The line to start serializing (inclusive). - */ - start: number - /** - * The line to end serializing (inclusive). - */ - end: number -} - -export interface IHTMLSerializeOptions { - /** - * The number of rows in the scrollback buffer to serialize, starting from - * the bottom of the scrollback buffer. - */ - scrollback?: number - /** - * Whether to only serialize the selection. - * Default: false - */ - onlySelection?: boolean - /** - * Whether to include the global background of the terminal. - * Default: false - */ - includeGlobalBackground?: boolean - /** - * The range to serialize. This is prioritized over onlySelection. - */ - range?: { - startLine: number - endLine: number - startCol: number - } -} - -// ============================================================================ -// Helper Functions -// ============================================================================ - -function constrain(value: number, low: number, high: number): number { - return Math.max(low, Math.min(value, high)) -} - -function equalFg(cell1: IBufferCell, cell2: IBufferCell): boolean { - return cell1.getFgColorMode() === cell2.getFgColorMode() && cell1.getFgColor() === cell2.getFgColor() -} - -function equalBg(cell1: IBufferCell, cell2: IBufferCell): boolean { - return cell1.getBgColorMode() === cell2.getBgColorMode() && cell1.getBgColor() === cell2.getBgColor() -} - -function equalFlags(cell1: IBufferCell, cell2: IBufferCell): boolean { - return ( - !!cell1.isInverse() === !!cell2.isInverse() && - !!cell1.isBold() === !!cell2.isBold() && - !!cell1.isUnderline() === !!cell2.isUnderline() && - !!cell1.isBlink() === !!cell2.isBlink() && - !!cell1.isInvisible() === !!cell2.isInvisible() && - !!cell1.isItalic() === !!cell2.isItalic() && - !!cell1.isDim() === !!cell2.isDim() && - !!cell1.isStrikethrough() === !!cell2.isStrikethrough() - ) -} - -// ============================================================================ -// Base Serialize Handler -// ============================================================================ - -abstract class BaseSerializeHandler { - constructor(protected readonly _buffer: IBuffer) {} - - public serialize(range: IBufferRange, excludeFinalCursorPosition?: boolean): string { - let oldCell = this._buffer.getNullCell() - - const startRow = range.start.y - const endRow = range.end.y - const startColumn = range.start.x - const endColumn = range.end.x - - this._beforeSerialize(endRow - startRow, startRow, endRow) - - for (let row = startRow; row <= endRow; row++) { - const line = this._buffer.getLine(row) - if (line) { - const startLineColumn = row === range.start.y ? startColumn : 0 - const endLineColumn = row === range.end.y ? endColumn : line.length - for (let col = startLineColumn; col < endLineColumn; col++) { - const c = line.getCell(col) - if (!c) { - continue - } - this._nextCell(c, oldCell, row, col) - oldCell = c - } - } - this._rowEnd(row, row === endRow) - } - - this._afterSerialize() - - return this._serializeString(excludeFinalCursorPosition) - } - - protected _nextCell(_cell: IBufferCell, _oldCell: IBufferCell, _row: number, _col: number): void {} - protected _rowEnd(_row: number, _isLastRow: boolean): void {} - protected _beforeSerialize(_rows: number, _startRow: number, _endRow: number): void {} - protected _afterSerialize(): void {} - protected _serializeString(_excludeFinalCursorPosition?: boolean): string { - return "" - } -} - -// ============================================================================ -// String Serialize Handler -// ============================================================================ - -class StringSerializeHandler extends BaseSerializeHandler { - private _rowIndex: number = 0 - private _allRows: string[] = [] - private _allRowSeparators: string[] = [] - private _currentRow: string = "" - private _nullCellCount: number = 0 - private _cursorStyle: IBufferCell - private _cursorStyleRow: number = 0 - private _cursorStyleCol: number = 0 - private _backgroundCell: IBufferCell - private _firstRow: number = 0 - private _lastCursorRow: number = 0 - private _lastCursorCol: number = 0 - private _lastContentCursorRow: number = 0 - private _lastContentCursorCol: number = 0 - private _thisRowLastChar: IBufferCell - private _thisRowLastSecondChar: IBufferCell - private _nextRowFirstChar: IBufferCell - - constructor( - buffer: IBuffer, - private readonly _terminal: ITerminalCore, - ) { - super(buffer) - this._cursorStyle = this._buffer.getNullCell() - this._backgroundCell = this._buffer.getNullCell() - this._thisRowLastChar = this._buffer.getNullCell() - this._thisRowLastSecondChar = this._buffer.getNullCell() - this._nextRowFirstChar = this._buffer.getNullCell() - } - - protected _beforeSerialize(rows: number, start: number, _end: number): void { - this._allRows = new Array<string>(rows) - this._lastContentCursorRow = start - this._lastCursorRow = start - this._firstRow = start - } - - protected _rowEnd(row: number, isLastRow: boolean): void { - // if there is colorful empty cell at line end, we must pad it back - if (this._nullCellCount > 0 && !equalBg(this._cursorStyle, this._backgroundCell)) { - this._currentRow += `\u001b[${this._nullCellCount}X` - } - - let rowSeparator = "" - - if (!isLastRow) { - // Enable BCE - if (row - this._firstRow >= this._terminal.rows) { - const line = this._buffer.getLine(this._cursorStyleRow) - const cell = line?.getCell(this._cursorStyleCol) - if (cell) { - this._backgroundCell = cell - } - } - - const currentLine = this._buffer.getLine(row)! - const nextLine = this._buffer.getLine(row + 1)! - - if (!nextLine.isWrapped) { - rowSeparator = "\r\n" - this._lastCursorRow = row + 1 - this._lastCursorCol = 0 - } else { - rowSeparator = "" - const thisRowLastChar = currentLine.getCell(currentLine.length - 1) - const thisRowLastSecondChar = currentLine.getCell(currentLine.length - 2) - const nextRowFirstChar = nextLine.getCell(0) - - if (thisRowLastChar) this._thisRowLastChar = thisRowLastChar - if (thisRowLastSecondChar) this._thisRowLastSecondChar = thisRowLastSecondChar - if (nextRowFirstChar) this._nextRowFirstChar = nextRowFirstChar - - const isNextRowFirstCharDoubleWidth = this._nextRowFirstChar.getWidth() > 1 - - let isValid = false - - if ( - this._nextRowFirstChar.getChars() && - (isNextRowFirstCharDoubleWidth ? this._nullCellCount <= 1 : this._nullCellCount <= 0) - ) { - if ( - (this._thisRowLastChar.getChars() || this._thisRowLastChar.getWidth() === 0) && - equalBg(this._thisRowLastChar, this._nextRowFirstChar) - ) { - isValid = true - } - - if ( - isNextRowFirstCharDoubleWidth && - (this._thisRowLastSecondChar.getChars() || this._thisRowLastSecondChar.getWidth() === 0) && - equalBg(this._thisRowLastChar, this._nextRowFirstChar) && - equalBg(this._thisRowLastSecondChar, this._nextRowFirstChar) - ) { - isValid = true - } - } - - if (!isValid) { - rowSeparator = "-".repeat(this._nullCellCount + 1) - rowSeparator += "\u001b[1D\u001b[1X" - - if (this._nullCellCount > 0) { - rowSeparator += "\u001b[A" - rowSeparator += `\u001b[${currentLine.length - this._nullCellCount}C` - rowSeparator += `\u001b[${this._nullCellCount}X` - rowSeparator += `\u001b[${currentLine.length - this._nullCellCount}D` - rowSeparator += "\u001b[B" - } - - this._lastContentCursorRow = row + 1 - this._lastContentCursorCol = 0 - this._lastCursorRow = row + 1 - this._lastCursorCol = 0 - } - } - } - - this._allRows[this._rowIndex] = this._currentRow - this._allRowSeparators[this._rowIndex++] = rowSeparator - this._currentRow = "" - this._nullCellCount = 0 - } - - private _diffStyle(cell: IBufferCell, oldCell: IBufferCell): number[] { - const sgrSeq: number[] = [] - const fgChanged = !equalFg(cell, oldCell) - const bgChanged = !equalBg(cell, oldCell) - const flagsChanged = !equalFlags(cell, oldCell) - - if (fgChanged || bgChanged || flagsChanged) { - if (this._isAttributeDefault(cell)) { - if (!this._isAttributeDefault(oldCell)) { - sgrSeq.push(0) - } - } else { - if (fgChanged) { - const color = cell.getFgColor() - const mode = cell.getFgColorMode() - if (mode === 2) { - // RGB - sgrSeq.push(38, 2, (color >>> 16) & 0xff, (color >>> 8) & 0xff, color & 0xff) - } else if (mode === 1) { - // Palette - if (color >= 16) { - sgrSeq.push(38, 5, color) - } else { - sgrSeq.push(color & 8 ? 90 + (color & 7) : 30 + (color & 7)) - } - } else { - sgrSeq.push(39) - } - } - if (bgChanged) { - const color = cell.getBgColor() - const mode = cell.getBgColorMode() - if (mode === 2) { - // RGB - sgrSeq.push(48, 2, (color >>> 16) & 0xff, (color >>> 8) & 0xff, color & 0xff) - } else if (mode === 1) { - // Palette - if (color >= 16) { - sgrSeq.push(48, 5, color) - } else { - sgrSeq.push(color & 8 ? 100 + (color & 7) : 40 + (color & 7)) - } - } else { - sgrSeq.push(49) - } - } - if (flagsChanged) { - if (!!cell.isInverse() !== !!oldCell.isInverse()) { - sgrSeq.push(cell.isInverse() ? 7 : 27) - } - if (!!cell.isBold() !== !!oldCell.isBold()) { - sgrSeq.push(cell.isBold() ? 1 : 22) - } - if (!!cell.isUnderline() !== !!oldCell.isUnderline()) { - sgrSeq.push(cell.isUnderline() ? 4 : 24) - } - if (!!cell.isBlink() !== !!oldCell.isBlink()) { - sgrSeq.push(cell.isBlink() ? 5 : 25) - } - if (!!cell.isInvisible() !== !!oldCell.isInvisible()) { - sgrSeq.push(cell.isInvisible() ? 8 : 28) - } - if (!!cell.isItalic() !== !!oldCell.isItalic()) { - sgrSeq.push(cell.isItalic() ? 3 : 23) - } - if (!!cell.isDim() !== !!oldCell.isDim()) { - sgrSeq.push(cell.isDim() ? 2 : 22) - } - if (!!cell.isStrikethrough() !== !!oldCell.isStrikethrough()) { - sgrSeq.push(cell.isStrikethrough() ? 9 : 29) - } - } - } - } - - return sgrSeq - } - - private _isAttributeDefault(cell: IBufferCell): boolean { - return ( - cell.getFgColorMode() === 0 && - cell.getBgColorMode() === 0 && - !cell.isBold() && - !cell.isItalic() && - !cell.isUnderline() && - !cell.isBlink() && - !cell.isInverse() && - !cell.isInvisible() && - !cell.isDim() && - !cell.isStrikethrough() - ) - } - - protected _nextCell(cell: IBufferCell, _oldCell: IBufferCell, row: number, col: number): void { - const isPlaceHolderCell = cell.getWidth() === 0 - - if (isPlaceHolderCell) { - return - } - - const isEmptyCell = cell.getChars() === "" - - const sgrSeq = this._diffStyle(cell, this._cursorStyle) - - const styleChanged = isEmptyCell ? !equalBg(this._cursorStyle, cell) : sgrSeq.length > 0 - - if (styleChanged) { - if (this._nullCellCount > 0) { - if (!equalBg(this._cursorStyle, this._backgroundCell)) { - this._currentRow += `\u001b[${this._nullCellCount}X` - } - this._currentRow += `\u001b[${this._nullCellCount}C` - this._nullCellCount = 0 - } - - this._lastContentCursorRow = this._lastCursorRow = row - this._lastContentCursorCol = this._lastCursorCol = col - - this._currentRow += `\u001b[${sgrSeq.join(";")}m` - - const line = this._buffer.getLine(row) - const cellFromLine = line?.getCell(col) - if (cellFromLine) { - this._cursorStyle = cellFromLine - this._cursorStyleRow = row - this._cursorStyleCol = col - } - } - - if (isEmptyCell) { - this._nullCellCount += cell.getWidth() - } else { - if (this._nullCellCount > 0) { - if (equalBg(this._cursorStyle, this._backgroundCell)) { - this._currentRow += `\u001b[${this._nullCellCount}C` - } else { - this._currentRow += `\u001b[${this._nullCellCount}X` - this._currentRow += `\u001b[${this._nullCellCount}C` - } - this._nullCellCount = 0 - } - - this._currentRow += cell.getChars() - - this._lastContentCursorRow = this._lastCursorRow = row - this._lastContentCursorCol = this._lastCursorCol = col + cell.getWidth() - } - } - - protected _serializeString(excludeFinalCursorPosition?: boolean): string { - let rowEnd = this._allRows.length - - if (this._buffer.length - this._firstRow <= this._terminal.rows) { - rowEnd = this._lastContentCursorRow + 1 - this._firstRow - this._lastCursorCol = this._lastContentCursorCol - this._lastCursorRow = this._lastContentCursorRow - } - - let content = "" - - for (let i = 0; i < rowEnd; i++) { - content += this._allRows[i] - if (i + 1 < rowEnd) { - content += this._allRowSeparators[i] - } - } - - if (!excludeFinalCursorPosition) { - // Get cursor position relative to viewport (1-indexed for ANSI) - // cursorY is relative to the viewport, cursorX is column position - const cursorRow = this._buffer.cursorY + 1 // 1-indexed - const cursorCol = this._buffer.cursorX + 1 // 1-indexed - - // Use absolute cursor positioning (CUP - Cursor Position) - // This is more reliable than relative moves which depend on knowing - // exactly where the cursor ended up after all the content - content += `\u001b[${cursorRow};${cursorCol}H` - } - - return content - } -} - -// ============================================================================ -// SerializeAddon Class -// ============================================================================ - -export class SerializeAddon implements ITerminalAddon { - private _terminal?: ITerminalCore - - /** - * Activate the addon (called by Terminal.loadAddon) - */ - public activate(terminal: ITerminalCore): void { - this._terminal = terminal - } - - /** - * Dispose the addon and clean up resources - */ - public dispose(): void { - this._terminal = undefined - } - - /** - * Serializes terminal rows into a string that can be written back to the - * terminal to restore the state. The cursor will also be positioned to the - * correct cell. - * - * @param options Custom options to allow control over what gets serialized. - */ - public serialize(options?: ISerializeOptions): string { - if (!this._terminal) { - throw new Error("Cannot use addon until it has been loaded") - } - - const terminal = this._terminal as any - const buffer = terminal.buffer - - if (!buffer) { - return "" - } - - const activeBuffer = buffer.active || buffer.normal - if (!activeBuffer) { - return "" - } - - let content = options?.range - ? this._serializeBufferByRange(activeBuffer, options.range, true) - : this._serializeBufferByScrollback(activeBuffer, options?.scrollback) - - // Handle alternate buffer if active and not excluded - if (!options?.excludeAltBuffer) { - const altBuffer = buffer.alternate - if (altBuffer && buffer.active?.type === "alternate") { - const alternateContent = this._serializeBufferByScrollback(altBuffer, undefined) - content += `\u001b[?1049h\u001b[H${alternateContent}` - } - } - - return content - } - - /** - * Serializes terminal content as plain text (no escape sequences) - * @param options Custom options to allow control over what gets serialized. - */ - public serializeAsText(options?: { scrollback?: number; trimWhitespace?: boolean }): string { - if (!this._terminal) { - throw new Error("Cannot use addon until it has been loaded") - } - - const terminal = this._terminal as any - const buffer = terminal.buffer - - if (!buffer) { - return "" - } - - const activeBuffer = buffer.active || buffer.normal - if (!activeBuffer) { - return "" - } - - const maxRows = activeBuffer.length - const scrollback = options?.scrollback - const correctRows = scrollback === undefined ? maxRows : constrain(scrollback + this._terminal.rows, 0, maxRows) - - const startRow = maxRows - correctRows - const endRow = maxRows - 1 - const lines: string[] = [] - - for (let row = startRow; row <= endRow; row++) { - const line = activeBuffer.getLine(row) - if (line) { - const text = line.translateToString(options?.trimWhitespace ?? true) - lines.push(text) - } - } - - // Trim trailing empty lines if requested - if (options?.trimWhitespace) { - while (lines.length > 0 && lines[lines.length - 1] === "") { - lines.pop() - } - } - - return lines.join("\n") - } - - private _serializeBufferByScrollback(buffer: IBuffer, scrollback?: number): string { - const maxRows = buffer.length - const rows = this._terminal?.rows ?? 24 - const correctRows = scrollback === undefined ? maxRows : constrain(scrollback + rows, 0, maxRows) - return this._serializeBufferByRange( - buffer, - { - start: maxRows - correctRows, - end: maxRows - 1, - }, - false, - ) - } - - private _serializeBufferByRange( - buffer: IBuffer, - range: ISerializeRange, - excludeFinalCursorPosition: boolean, - ): string { - const handler = new StringSerializeHandler(buffer, this._terminal!) - const cols = this._terminal?.cols ?? 80 - return handler.serialize( - { - start: { x: 0, y: range.start }, - end: { x: cols, y: range.end }, - }, - excludeFinalCursorPosition, - ) - } -} diff --git a/packages/desktop/src/components/terminal.tsx b/packages/desktop/src/components/terminal.tsx deleted file mode 100644 index 49a45a432..000000000 --- a/packages/desktop/src/components/terminal.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { init, Terminal as Term, FitAddon } from "ghostty-web" -import { ComponentProps, onCleanup, onMount, splitProps } from "solid-js" -import { createReconnectingWS, ReconnectingWebSocket } from "@solid-primitives/websocket" -import { useSDK } from "@/context/sdk" -import { SerializeAddon } from "@/addons/serialize" -import { LocalPTY } from "@/context/session" - -await init() - -export interface TerminalProps extends ComponentProps<"div"> { - pty: LocalPTY - onSubmit?: () => void - onCleanup?: (pty: LocalPTY) => void -} - -export const Terminal = (props: TerminalProps) => { - const sdk = useSDK() - let container!: HTMLDivElement - const [local, others] = splitProps(props, ["pty", "class", "classList"]) - let ws: ReconnectingWebSocket - let term: Term - let serializeAddon: SerializeAddon - let fitAddon: FitAddon - - onMount(async () => { - ws = createReconnectingWS(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`) - term = new Term({ - cursorBlink: true, - fontSize: 14, - fontFamily: "TX-02, monospace", - allowTransparency: true, - theme: { - background: "#191515", - foreground: "#d4d4d4", - }, - scrollback: 10_000, - }) - term.attachCustomKeyEventHandler((event) => { - // allow for ctrl-` to toggle terminal in parent - if (event.ctrlKey && event.key.toLowerCase() === "`") { - event.preventDefault() - return true - } - return false - }) - - fitAddon = new FitAddon() - serializeAddon = new SerializeAddon() - term.loadAddon(serializeAddon) - term.loadAddon(fitAddon) - - term.open(container) - - if (local.pty.buffer) { - const originalSize = { cols: term.cols, rows: term.rows } - let resized = false - if (local.pty.rows && local.pty.cols) { - term.resize(local.pty.cols, local.pty.rows) - resized = true - } - term.write(local.pty.buffer) - if (local.pty.scrollY) { - term.scrollToLine(local.pty.scrollY) - } - if (resized) { - term.resize(originalSize.cols, originalSize.rows) - } - } - - container.focus() - - fitAddon.fit() - fitAddon.observeResize() - window.addEventListener("resize", () => fitAddon.fit()) - term.onResize(async (size) => { - if (ws && ws.readyState === WebSocket.OPEN) { - await sdk.client.pty.update({ - path: { id: local.pty.id }, - body: { - size: { - cols: size.cols, - rows: size.rows, - }, - }, - }) - } - }) - term.onData((data) => { - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(data) - } - }) - term.onKey((key) => { - if (key.key == "Enter") { - props.onSubmit?.() - } - }) - // term.onScroll((ydisp) => { - // console.log("Scroll position:", ydisp) - // }) - ws.addEventListener("open", () => { - console.log("WebSocket connected") - sdk.client.pty.update({ - path: { id: local.pty.id }, - body: { - size: { - cols: term.cols, - rows: term.rows, - }, - }, - }) - }) - ws.addEventListener("message", (event) => { - term.write(event.data) - }) - ws.addEventListener("error", (error) => { - console.error("WebSocket error:", error) - }) - ws.addEventListener("close", () => { - console.log("WebSocket disconnected") - }) - }) - - onCleanup(() => { - if (serializeAddon && props.onCleanup) { - const buffer = serializeAddon.serialize() - props.onCleanup({ - ...local.pty, - buffer, - rows: term.rows, - cols: term.cols, - scrollY: term.getViewportY(), - }) - } - ws?.close() - term?.dispose() - }) - - return ( - <div - ref={container} - data-component="terminal" - classList={{ - ...(local.classList ?? {}), - "size-full px-6 py-3 font-mono": true, - [local.class ?? ""]: !!local.class, - }} - {...others} - /> - ) -} diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx index ca736e84e..81e8b537a 100644 --- a/packages/desktop/src/context/layout.tsx +++ b/packages/desktop/src/context/layout.tsx @@ -15,16 +15,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( opened: true, width: 280, }, - terminal: { - opened: false, - height: 280, - }, review: { state: "pane" as "pane" | "tab", }, }), { - name: "____default-layout", + name: "___default-layout", }, ) @@ -65,22 +61,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setStore("sidebar", "width", width) }, }, - terminal: { - opened: createMemo(() => store.terminal.opened), - open() { - setStore("terminal", "opened", true) - }, - close() { - setStore("terminal", "opened", false) - }, - toggle() { - setStore("terminal", "opened", (x) => !x) - }, - height: createMemo(() => store.terminal.height), - resize(height: number) { - setStore("terminal", "height", height) - }, - }, review: { state: createMemo(() => store.review?.state ?? "closed"), pane() { diff --git a/packages/desktop/src/context/sdk.tsx b/packages/desktop/src/context/sdk.tsx index 144202ee2..81b32035a 100644 --- a/packages/desktop/src/context/sdk.tsx +++ b/packages/desktop/src/context/sdk.tsx @@ -27,6 +27,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ abort.abort() }) - return { directory: props.directory, client: sdk, event: emitter, url: globalSDK.url } + return { directory: props.directory, client: sdk, event: emitter } }, }) diff --git a/packages/desktop/src/context/session.tsx b/packages/desktop/src/context/session.tsx index 4e9fe71f8..72098a939 100644 --- a/packages/desktop/src/context/session.tsx +++ b/packages/desktop/src/context/session.tsx @@ -8,25 +8,14 @@ import { pipe, sumBy } from "remeda" import { AssistantMessage, UserMessage } from "@opencode-ai/sdk" import { useParams } from "@solidjs/router" import { base64Encode } from "@/utils" -import { useSDK } from "./sdk" - -export type LocalPTY = { - id: string - title: string - rows?: number - cols?: number - buffer?: string - scrollY?: number -} export const { use: useSession, provider: SessionProvider } = createSimpleContext({ name: "Session", init: () => { - const sdk = useSDK() const params = useParams() const sync = useSync() const name = createMemo( - () => `______${base64Encode(sync.data.project.worktree)}/session${params.id ? "/" + params.id : ""}`, + () => `___${base64Encode(sync.data.project.worktree)}/session${params.id ? "/" + params.id : ""}`, ) const [store, setStore] = makePersisted( @@ -34,21 +23,16 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex messageId?: string tabs: { active?: string - all: string[] + opened: string[] } prompt: Prompt cursor?: number - terminals: { - active?: string - all: LocalPTY[] - } }>({ tabs: { - all: [], + opened: [], }, prompt: clonePrompt(DEFAULT_PROMPT), cursor: undefined, - terminals: { all: [] }, }), { name: name(), @@ -154,7 +138,7 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex setStore("tabs", "active", tab) }, setOpenedTabs(tabs: string[]) { - setStore("tabs", "all", tabs) + setStore("tabs", "opened", tabs) }, async openTab(tab: string) { if (tab === "chat") { @@ -162,8 +146,8 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex return } if (tab !== "review") { - if (!store.tabs.all.includes(tab)) { - setStore("tabs", "all", [...store.tabs.all, tab]) + if (!store.tabs.opened.includes(tab)) { + setStore("tabs", "opened", [...store.tabs.opened, tab]) } } setStore("tabs", "active", tab) @@ -172,88 +156,28 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex batch(() => { setStore( "tabs", - "all", - store.tabs.all.filter((x) => x !== tab), + "opened", + store.tabs.opened.filter((x) => x !== tab), ) if (store.tabs.active === tab) { - const index = store.tabs.all.findIndex((f) => f === tab) - const previous = store.tabs.all[Math.max(0, index - 1)] + const index = store.tabs.opened.findIndex((f) => f === tab) + const previous = store.tabs.opened[Math.max(0, index - 1)] setStore("tabs", "active", previous) } }) }, moveTab(tab: string, to: number) { - const index = store.tabs.all.findIndex((f) => f === tab) + const index = store.tabs.opened.findIndex((f) => f === tab) if (index === -1) return setStore( "tabs", - "all", + "opened", produce((opened) => { opened.splice(to, 0, opened.splice(index, 1)[0]) }), ) }, }, - terminal: { - all: createMemo(() => Object.values(store.terminals.all)), - active: createMemo(() => store.terminals.active), - new() { - sdk.client.pty.create({ body: { title: `Terminal ${store.terminals.all.length + 1}` } }).then((pty) => { - const id = pty.data?.id - if (!id) return - batch(() => { - setStore("terminals", "all", [ - ...store.terminals.all, - { - id, - title: pty.data?.title ?? "Terminal", - // rows: pty.data?.rows ?? 24, - // cols: pty.data?.cols ?? 80, - // buffer: "", - // scrollY: 0, - }, - ]) - setStore("terminals", "active", id) - }) - }) - }, - update(pty: Partial<LocalPTY> & { id: string }) { - setStore("terminals", "all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x))) - sdk.client.pty.update({ - path: { id: pty.id }, - body: { title: pty.title, size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined }, - }) - }, - open(id: string) { - setStore("terminals", "active", id) - }, - async close(id: string) { - batch(() => { - setStore( - "terminals", - "all", - store.terminals.all.filter((x) => x.id !== id), - ) - if (store.terminals.active === id) { - const index = store.terminals.all.findIndex((f) => f.id === id) - const previous = store.tabs.all[Math.max(0, index - 1)] - setStore("terminals", "active", previous) - } - }) - await sdk.client.pty.remove({ path: { id } }) - }, - move(id: string, to: number) { - const index = store.terminals.all.findIndex((f) => f.id === id) - if (index === -1) return - setStore( - "terminals", - "all", - produce((all) => { - all.splice(to, 0, all.splice(index, 1)[0]) - }), - ) - }, - }, } }, }) diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 106a2e733..15180c885 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -1,9 +1,9 @@ import { createMemo, For, ParentProps, Show } from "solid-js" import { DateTime } from "luxon" -import { A, useNavigate, useParams } from "@solidjs/router" +import { A, useParams } from "@solidjs/router" import { useLayout } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" -import { base64Decode, base64Encode } from "@/utils" +import { base64Encode } from "@/utils" import { Mark } from "@opencode-ai/ui/logo" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" @@ -12,21 +12,11 @@ import { Tooltip } from "@opencode-ai/ui/tooltip" import { Collapsible } from "@opencode-ai/ui/collapsible" import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { getFilename } from "@opencode-ai/util/path" -import { Select } from "@opencode-ai/ui/select" -import { Session } from "@opencode-ai/sdk/client" export default function Layout(props: ParentProps) { - const navigate = useNavigate() const params = useParams() const globalSync = useGlobalSync() const layout = useLayout() - const currentDirectory = createMemo(() => base64Decode(params.dir ?? "")) - const sessions = createMemo(() => globalSync.child(currentDirectory())[0].session ?? []) - const currentSession = createMemo(() => sessions().find((s) => s.id === params.id) ?? sessions().at(0)) - - function navigateToSession(session: Session | undefined) { - navigate(`/${params.dir}/session/${session?.id}`) - } const handleOpenProject = async () => { // layout.projects.open(dir.) @@ -34,7 +24,7 @@ export default function Layout(props: ParentProps) { return ( <div class="relative h-screen flex flex-col"> - <header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex"> + <header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base"> <A href="/" classList={{ @@ -46,110 +36,16 @@ export default function Layout(props: ParentProps) { > <Mark class="shrink-0" /> </A> - <div class="pl-4 px-6 flex items-center justify-between gap-4 w-full"> - <div class="flex items-center gap-3"> - <div class="flex items-center gap-2"> - <Select - options={layout.projects.list().map((project) => getFilename(project.directory))} - current={getFilename(currentDirectory())} - class="text-14-regular text-text-base" - variant="ghost" - /> - <div class="text-text-weaker">/</div> - <Select - options={sessions()} - current={currentSession()} - label={(x) => x.title} - value={(x) => x.id} - onSelect={navigateToSession} - class="text-14-regular text-text-base max-w-3xs" - variant="ghost" - /> - </div> - <Button as={A} href={`/${params.dir}/session`} icon="plus-small"> - New session - </Button> - </div> - <div class="flex items-center gap-4"> - <Tooltip - class="shrink-0" - value={ - <div class="flex items-center gap-2"> - <span>Toggle terminal</span> - <span class="text-icon-base text-12-medium">Ctrl `</span> - </div> - } - > - <Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}> - <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0"> - <Icon - size="small" - name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"} - class="group-hover/terminal-toggle:hidden" - /> - <Icon - size="small" - name="layout-bottom-partial" - class="hidden group-hover/terminal-toggle:inline-block" - /> - <Icon - size="small" - name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"} - class="hidden group-active/terminal-toggle:inline-block" - /> - </div> - </Button> - </Tooltip> - </div> - </div> </header> <div class="h-[calc(100vh-3rem)] flex"> <div classList={{ - "relative @container w-12 pb-5 shrink-0 bg-background-base": true, + "@container w-12 pb-5 shrink-0 bg-background-base": true, "flex flex-col gap-5.5 items-start self-stretch justify-between": true, "border-r border-border-weak-base": true, }} style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }} > - <Show when={layout.sidebar.opened()}> - <div - class="absolute inset-y-0 right-0 z-10 w-2 translate-x-1/2 cursor-ew-resize" - onMouseDown={(e) => { - e.preventDefault() - const startX = e.clientX - const startWidth = layout.sidebar.width() - const maxWidth = window.innerWidth * 0.3 - const minWidth = 150 - const collapseThreshold = 80 - let currentWidth = startWidth - - document.body.style.userSelect = "none" - document.body.style.overflow = "hidden" - - const onMouseMove = (moveEvent: MouseEvent) => { - const deltaX = moveEvent.clientX - startX - currentWidth = startWidth + deltaX - const clampedWidth = Math.min(maxWidth, Math.max(minWidth, currentWidth)) - layout.sidebar.resize(clampedWidth) - } - - const onMouseUp = () => { - document.body.style.userSelect = "" - document.body.style.overflow = "" - document.removeEventListener("mousemove", onMouseMove) - document.removeEventListener("mouseup", onMouseUp) - - if (currentWidth < collapseThreshold) { - layout.sidebar.close() - } - } - - document.addEventListener("mousemove", onMouseMove) - document.addEventListener("mouseup", onMouseUp) - }} - /> - </Show> <div class="grow flex flex-col items-start self-stretch gap-4 p-2 min-h-0"> <Tooltip class="shrink-0" placement="right" value="Toggle sidebar" inactive={layout.sidebar.opened()}> <Button @@ -301,7 +197,7 @@ export default function Layout(props: ParentProps) { </Tooltip> </div> </div> - <main class="size-full overflow-x-hidden flex flex-col items-start">{props.children}</main> + <main class="size-full overflow-x-hidden">{props.children}</main> </div> </div> ) diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index 773625334..d6ce62b70 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -1,4 +1,4 @@ -import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo, createEffect } from "solid-js" +import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo } from "solid-js" import { useLocal, type LocalFile } from "@/context/local" import { createStore } from "solid-js/store" import { PromptInput } from "@/components/prompt-input" @@ -31,7 +31,6 @@ import { useSession } from "@/context/session" import { useLayout } from "@/context/layout" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { Diff } from "@opencode-ai/ui/diff" -import { Terminal } from "@/components/terminal" export default function Page() { const layout = useLayout() @@ -55,14 +54,6 @@ export default function Page() { document.removeEventListener("keydown", handleKeyDown) }) - createEffect(() => { - if (layout.terminal.opened()) { - if (session.terminal.all().length === 0) { - session.terminal.new() - } - } - }) - const handleKeyDown = (event: KeyboardEvent) => { if (event.getModifierState(MOD) && event.shiftKey && event.key.toLowerCase() === "p") { event.preventDefault() @@ -82,16 +73,6 @@ export default function Page() { document.documentElement.setAttribute("data-theme", nextTheme) return } - if (event.ctrlKey && event.key.toLowerCase() === "`") { - event.preventDefault() - layout.terminal.toggle() - return - } - - // @ts-expect-error - if (document.activeElement?.dataset?.component === "terminal") { - return - } const focused = document.activeElement === inputRef if (focused) { @@ -160,7 +141,7 @@ export default function Page() { const handleDragOver = (event: DragEvent) => { const { draggable, droppable } = event if (draggable && droppable) { - const currentTabs = session.layout.tabs.all + const currentTabs = session.layout.tabs.opened const fromIndex = currentTabs?.indexOf(draggable.id.toString()) const toIndex = currentTabs?.indexOf(droppable.id.toString()) if (fromIndex !== toIndex && toIndex !== undefined) { @@ -278,397 +259,317 @@ export default function Page() { const wide = createMemo(() => layout.review.state() === "tab" || !session.diffs().length) return ( - <div class="relative bg-background-base size-full overflow-x-hidden flex flex-col items-start"> - <div class="min-h-0 grow w-full"> - <DragDropProvider - onDragStart={handleDragStart} - onDragEnd={handleDragEnd} - onDragOver={handleDragOver} - collisionDetector={closestCenter} - > - <DragDropSensors /> - <ConstrainDragYAxis /> - <Tabs value={session.layout.tabs.active ?? "chat"} onChange={session.layout.openTab}> - <div class="sticky top-0 shrink-0 flex"> - <Tabs.List> - <Tabs.Trigger value="chat"> - <div class="flex gap-x-[17px] items-center"> - <div>Session</div> - <Tooltip - value={`${new Intl.NumberFormat("en-US", { - notation: "compact", - compactDisplay: "short", - }).format(session.usage.tokens() ?? 0)} Tokens`} - class="flex items-center gap-1.5" - > - <ProgressCircle percentage={session.usage.context() ?? 0} /> - <div class="text-14-regular text-text-weak text-left w-7">{session.usage.context() ?? 0}%</div> - </Tooltip> - </div> - </Tabs.Trigger> - <Show when={layout.review.state() === "tab" && session.diffs().length}> - <Tabs.Trigger - value="review" - closeButton={ - <IconButton icon="collapse" size="normal" variant="ghost" onClick={layout.review.pane} /> - } + <div class="relative bg-background-base size-full overflow-x-hidden"> + <DragDropProvider + onDragStart={handleDragStart} + onDragEnd={handleDragEnd} + onDragOver={handleDragOver} + collisionDetector={closestCenter} + > + <DragDropSensors /> + <ConstrainDragYAxis /> + <Tabs value={session.layout.tabs.active ?? "chat"} onChange={session.layout.openTab}> + <div class="sticky top-0 shrink-0 flex"> + <Tabs.List> + <Tabs.Trigger value="chat"> + <div class="flex gap-x-[17px] items-center"> + <div>Session</div> + <Tooltip + value={`${new Intl.NumberFormat("en-US", { + notation: "compact", + compactDisplay: "short", + }).format(session.usage.tokens() ?? 0)} Tokens`} + class="flex items-center gap-1.5" > - <div class="flex items-center gap-3"> - <Show when={session.diffs()}> - <DiffChanges changes={session.diffs()} variant="bars" /> - </Show> - <div class="flex items-center gap-1.5"> - <div>Review</div> - <Show when={session.info()?.summary?.files}> - <div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base"> - {session.info()?.summary?.files ?? 0} - </div> - </Show> - </div> - </div> - </Tabs.Trigger> - </Show> - <SortableProvider ids={session.layout.tabs.all ?? []}> - <For each={session.layout.tabs.all ?? []}> - {(tab) => ( - <SortableTab tab={tab} onTabClick={handleTabClick} onTabClose={session.layout.closeTab} /> - )} - </For> - </SortableProvider> - <div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3"> - <Tooltip value="Open file" class="flex items-center"> - <IconButton - icon="plus-small" - variant="ghost" - iconSize="large" - onClick={() => setStore("fileSelectOpen", true)} - /> + <ProgressCircle percentage={session.usage.context() ?? 0} /> + <div class="text-14-regular text-text-weak text-left w-7">{session.usage.context() ?? 0}%</div> </Tooltip> </div> - </Tabs.List> - </div> - <Tabs.Content value="chat" class="@container select-text flex flex-col flex-1 min-h-0 overflow-y-hidden"> + </Tabs.Trigger> + <Show when={layout.review.state() === "tab" && session.diffs().length}> + <Tabs.Trigger + value="review" + closeButton={ + <IconButton icon="collapse" size="normal" variant="ghost" onClick={layout.review.pane} /> + } + > + <div class="flex items-center gap-3"> + <Show when={session.diffs()}> + <DiffChanges changes={session.diffs()} variant="bars" /> + </Show> + <div class="flex items-center gap-1.5"> + <div>Review</div> + <Show when={session.info()?.summary?.files}> + <div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base"> + {session.info()?.summary?.files ?? 0} + </div> + </Show> + </div> + </div> + </Tabs.Trigger> + </Show> + <SortableProvider ids={session.layout.tabs.opened ?? []}> + <For each={session.layout.tabs.opened ?? []}> + {(tab) => <SortableTab tab={tab} onTabClick={handleTabClick} onTabClose={session.layout.closeTab} />} + </For> + </SortableProvider> + <div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3"> + <Tooltip value="Open file" class="flex items-center"> + <IconButton + icon="plus-small" + variant="ghost" + iconSize="large" + onClick={() => setStore("fileSelectOpen", true)} + /> + </Tooltip> + </div> + </Tabs.List> + </div> + <Tabs.Content value="chat" class="@container select-text flex flex-col flex-1 min-h-0 overflow-y-hidden"> + <div + classList={{ + "w-full flex-1 min-h-0": true, + grid: layout.review.state() === "tab", + flex: layout.review.state() === "pane", + }} + > <div classList={{ - "w-full flex-1 min-h-0": true, - grid: layout.review.state() === "tab", - flex: layout.review.state() === "pane", + "relative shrink-0 py-3 flex flex-col gap-6 flex-1 min-h-0 w-full": true, + "max-w-146 mx-auto": !wide(), }} > - <div - classList={{ - "relative shrink-0 py-3 flex flex-col gap-6 flex-1 min-h-0 w-full": true, - "max-w-146 mx-auto": !wide(), - }} - > - <Switch> - <Match when={session.id}> - <div class="flex items-start justify-start h-full min-h-0"> - <SessionMessageRail - messages={session.messages.user()} - current={session.messages.active()} - onMessageSelect={session.messages.setActive} - working={session.working()} - wide={wide()} - /> - <SessionTurn - sessionID={session.id!} - messageID={session.messages.active()?.id!} - classes={{ - root: "pb-20 flex-1 min-w-0", - content: "pb-20", - container: - "w-full " + - (wide() - ? "max-w-146 mx-auto px-6" - : session.messages.user().length > 1 - ? "pr-6 pl-18" - : "px-6"), - }} - diffComponent={Diff} - /> - </div> - </Match> - <Match when={true}> - <div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-146 mx-auto px-6"> - <div class="text-20-medium text-text-weaker">New session</div> - <div class="flex justify-center items-center gap-3"> - <Icon name="folder" size="small" /> - <div class="text-12-medium text-text-weak"> - {getDirectory(sync.data.path.directory)} - <span class="text-text-strong">{getFilename(sync.data.path.directory)}</span> - </div> + <Switch> + <Match when={session.id}> + <div class="flex items-start justify-start h-full min-h-0"> + <SessionMessageRail + messages={session.messages.user()} + current={session.messages.active()} + onMessageSelect={session.messages.setActive} + working={session.working()} + wide={wide()} + /> + <SessionTurn + sessionID={session.id!} + messageID={session.messages.active()?.id!} + classes={{ + root: "pb-20 flex-1 min-w-0", + content: "pb-20", + container: + "w-full " + + (wide() + ? "max-w-146 mx-auto px-6" + : session.messages.user().length > 1 + ? "pr-6 pl-18" + : "px-6"), + }} + diffComponent={Diff} + /> + </div> + </Match> + <Match when={true}> + <div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-146 mx-auto px-6"> + <div class="text-20-medium text-text-weaker">New session</div> + <div class="flex justify-center items-center gap-3"> + <Icon name="folder" size="small" /> + <div class="text-12-medium text-text-weak"> + {getDirectory(sync.data.path.directory)} + <span class="text-text-strong">{getFilename(sync.data.path.directory)}</span> </div> - <div class="flex justify-center items-center gap-3"> - <Icon name="pencil-line" size="small" /> - <div class="text-12-medium text-text-weak"> - Last modified - <span class="text-text-strong"> - {DateTime.fromMillis(sync.data.project.time.created).toRelative()} - </span> - </div> + </div> + <div class="flex justify-center items-center gap-3"> + <Icon name="pencil-line" size="small" /> + <div class="text-12-medium text-text-weak"> + Last modified + <span class="text-text-strong"> + {DateTime.fromMillis(sync.data.project.time.created).toRelative()} + </span> </div> </div> - </Match> - </Switch> - <div class="absolute inset-x-0 bottom-8 flex flex-col justify-center items-center z-50"> - <div class="w-full max-w-146 px-6"> - <PromptInput - ref={(el) => { - inputRef = el - }} - /> </div> - </div> - </div> - <Show when={layout.review.state() === "pane" && session.diffs().length}> - <div - classList={{ - "relative grow pt-3 flex-1 min-h-0 border-l border-border-weak-base": true, - }} - > - <SessionReview - classes={{ - root: "pb-20", - header: "px-6", - container: "px-6", + </Match> + </Switch> + <div class="absolute inset-x-0 bottom-8 flex flex-col justify-center items-center z-50"> + <div class="w-full max-w-146 px-6"> + <PromptInput + ref={(el) => { + inputRef = el }} - diffs={session.diffs()} - diffComponent={Diff} - actions={ - <Tooltip value="Open in tab"> - <IconButton - icon="expand" - variant="ghost" - onClick={() => { - layout.review.tab() - session.layout.setActiveTab("review") - }} - /> - </Tooltip> - } /> </div> - </Show> + </div> </div> - </Tabs.Content> - <Show when={layout.review.state() === "tab" && session.diffs().length}> - <Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden"> + <Show when={layout.review.state() === "pane" && session.diffs().length}> <div classList={{ - "relative pt-3 flex-1 min-h-0 overflow-hidden": true, + "relative grow pt-3 flex-1 min-h-0 border-l border-border-weak-base": true, }} > <SessionReview classes={{ - root: "pb-40", + root: "pb-20", header: "px-6", container: "px-6", }} diffs={session.diffs()} diffComponent={Diff} - split + actions={ + <Tooltip value="Open in tab"> + <IconButton + icon="expand" + variant="ghost" + onClick={() => { + layout.review.tab() + session.layout.setActiveTab("review") + }} + /> + </Tooltip> + } /> </div> - </Tabs.Content> - </Show> - <For each={session.layout.tabs.all}> - {(tab) => { - const [file] = createResource( - () => tab, - async (tab) => { - if (tab.startsWith("file://")) { - return local.file.node(tab.replace("file://", "")) - } - return undefined - }, - ) - return ( - <Tabs.Content value={tab} class="select-text mt-3"> - <Switch> - <Match when={file()}> - {(f) => ( - <Code - file={{ name: f().path, contents: f().content?.content ?? "" }} - overflow="scroll" - class="pb-40" - /> - )} - </Match> - </Switch> - </Tabs.Content> - ) - }} - </For> - </Tabs> - <DragOverlay> - <Show when={store.activeDraggable}> - {(draggedFile) => { - const [file] = createResource( - () => draggedFile(), - async (tab) => { - if (tab.startsWith("file://")) { - return local.file.node(tab.replace("file://", "")) - } - return undefined - }, - ) - return ( - <div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent"> - <Show when={file()}>{(f) => <FileVisual active file={f()} />}</Show> - </div> - ) - }} - </Show> - </DragOverlay> - </DragDropProvider> - <Show when={session.layout.tabs.active}> - <div class="absolute inset-x-0 px-6 max-w-146 flex flex-col justify-center items-center z-50 mx-auto bottom-8"> - <PromptInput - ref={(el) => { - inputRef = el - }} - /> - </div> - </Show> - <div class="hidden shrink-0 w-56 p-2 h-full overflow-y-auto"> - {/* <FileTree path="" onFileClick={ handleTabClick} /> */} - </div> - <div class="hidden shrink-0 w-56 p-2"> - <Show - when={local.file.changes().length} - fallback={<div class="px-2 text-xs text-text-muted">No changes</div>} - > - <ul class=""> - <For each={local.file.changes()}> - {(path) => ( - <li> - <button - onClick={() => local.file.open(path, { view: "diff-unified", pinned: true })} - class="w-full flex items-center px-2 py-0.5 gap-x-2 text-text-muted grow min-w-0 hover:bg-background-element" - > - <FileIcon node={{ path, type: "file" }} class="shrink-0 size-3" /> - <span class="text-xs text-text whitespace-nowrap">{getFilename(path)}</span> - <span class="text-xs text-text-muted/60 whitespace-nowrap truncate min-w-0"> - {getDirectory(path)} - </span> - </button> - </li> - )} - </For> - </ul> - </Show> - </div> - <Show when={store.fileSelectOpen}> - <SelectDialog - defaultOpen - title="Select file" - placeholder="Search files" - emptyMessage="No files found" - items={local.file.searchFiles} - key={(x) => x} - onOpenChange={(open) => setStore("fileSelectOpen", open)} - onSelect={(x) => { - if (x) { - local.file.open(x) - return session.layout.openTab("file://" + x) - } - return undefined - }} - > - {(i) => ( + </Show> + </div> + </Tabs.Content> + <Show when={layout.review.state() === "tab" && session.diffs().length}> + <Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden"> <div classList={{ - "w-full flex items-center justify-between rounded-md": true, + "relative pt-3 flex-1 min-h-0 overflow-hidden": true, }} > - <div class="flex items-center gap-x-2 grow min-w-0"> - <FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" /> - <div class="flex items-center text-14-regular"> - <span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0"> - {getDirectory(i)} - </span> - <span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span> - </div> - </div> - <div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div> + <SessionReview + classes={{ + root: "pb-40", + header: "px-6", + container: "px-6", + }} + diffs={session.diffs()} + diffComponent={Diff} + split + /> </div> - )} - </SelectDialog> - </Show> - </div> - <Show when={layout.terminal.opened()}> - <div - class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base" - style={{ height: `${layout.terminal.height()}px` }} - > - <div - class="absolute inset-x-0 top-0 z-10 h-2 -translate-y-1/2 cursor-ns-resize" - onMouseDown={(e) => { - e.preventDefault() - const startY = e.clientY - const startHeight = layout.terminal.height() - const maxHeight = window.innerHeight * 0.6 - const minHeight = 100 - const collapseThreshold = 50 - let currentHeight = startHeight - - document.body.style.userSelect = "none" - document.body.style.overflow = "hidden" - - const onMouseMove = (moveEvent: MouseEvent) => { - const deltaY = startY - moveEvent.clientY - currentHeight = startHeight + deltaY - const clampedHeight = Math.min(maxHeight, Math.max(minHeight, currentHeight)) - layout.terminal.resize(clampedHeight) - } - - const onMouseUp = () => { - document.body.style.userSelect = "" - document.body.style.overflow = "" - document.removeEventListener("mousemove", onMouseMove) - document.removeEventListener("mouseup", onMouseUp) - - if (currentHeight < collapseThreshold) { - layout.terminal.close() - } - } - - document.addEventListener("mousemove", onMouseMove) - document.addEventListener("mouseup", onMouseUp) + </Tabs.Content> + </Show> + <For each={session.layout.tabs.opened}> + {(tab) => { + const [file] = createResource( + () => tab, + async (tab) => { + if (tab.startsWith("file://")) { + return local.file.node(tab.replace("file://", "")) + } + return undefined + }, + ) + return ( + <Tabs.Content value={tab} class="select-text mt-3"> + <Switch> + <Match when={file()}> + {(f) => ( + <Code + file={{ name: f().path, contents: f().content?.content ?? "" }} + overflow="scroll" + class="pb-40" + /> + )} + </Match> + </Switch> + </Tabs.Content> + ) + }} + </For> + </Tabs> + <DragOverlay> + <Show when={store.activeDraggable}> + {(draggedFile) => { + const [file] = createResource( + () => draggedFile(), + async (tab) => { + if (tab.startsWith("file://")) { + return local.file.node(tab.replace("file://", "")) + } + return undefined + }, + ) + return ( + <div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent"> + <Show when={file()}>{(f) => <FileVisual active file={f()} />}</Show> + </div> + ) + }} + </Show> + </DragOverlay> + </DragDropProvider> + <Show when={session.layout.tabs.active}> + <div class="absolute inset-x-0 px-6 max-w-146 flex flex-col justify-center items-center z-50 mx-auto bottom-8"> + <PromptInput + ref={(el) => { + inputRef = el }} /> - <Tabs variant="alt" value={session.terminal.active()} onChange={session.terminal.open}> - <Tabs.List class="h-10"> - <For each={session.terminal.all()}> - {(terminal) => ( - <Tabs.Trigger - value={terminal.id} - closeButton={ - session.terminal.all().length > 1 && ( - <IconButton icon="close" variant="ghost" onClick={() => session.terminal.close(terminal.id)} /> - ) - } + </div> + </Show> + <div class="hidden shrink-0 w-56 p-2 h-full overflow-y-auto"> + {/* <FileTree path="" onFileClick={ handleTabClick} /> */} + </div> + <div class="hidden shrink-0 w-56 p-2"> + <Show when={local.file.changes().length} fallback={<div class="px-2 text-xs text-text-muted">No changes</div>}> + <ul class=""> + <For each={local.file.changes()}> + {(path) => ( + <li> + <button + onClick={() => local.file.open(path, { view: "diff-unified", pinned: true })} + class="w-full flex items-center px-2 py-0.5 gap-x-2 text-text-muted grow min-w-0 hover:bg-background-element" > - {terminal.title} - </Tabs.Trigger> - )} - </For> - <div class="h-full flex items-center justify-center"> - <Tooltip value="Open file" class="flex items-center"> - <IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={session.terminal.new} /> - </Tooltip> - </div> - </Tabs.List> - <For each={session.terminal.all()}> - {(terminal) => ( - <Tabs.Content value={terminal.id}> - <Terminal pty={terminal} onCleanup={session.terminal.update} /> - </Tabs.Content> + <FileIcon node={{ path, type: "file" }} class="shrink-0 size-3" /> + <span class="text-xs text-text whitespace-nowrap">{getFilename(path)}</span> + <span class="text-xs text-text-muted/60 whitespace-nowrap truncate min-w-0"> + {getDirectory(path)} + </span> + </button> + </li> )} </For> - </Tabs> - </div> + </ul> + </Show> + </div> + <Show when={store.fileSelectOpen}> + <SelectDialog + defaultOpen + title="Select file" + placeholder="Search files" + emptyMessage="No files found" + items={local.file.searchFiles} + key={(x) => x} + onOpenChange={(open) => setStore("fileSelectOpen", open)} + onSelect={(x) => { + if (x) { + local.file.open(x) + return session.layout.openTab("file://" + x) + } + return undefined + }} + > + {(i) => ( + <div + classList={{ + "w-full flex items-center justify-between rounded-md": true, + }} + > + <div class="flex items-center gap-x-2 grow min-w-0"> + <FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" /> + <div class="flex items-center text-14-regular"> + <span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0"> + {getDirectory(i)} + </span> + <span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span> + </div> + </div> + <div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div> + </div> + )} + </SelectDialog> </Show> </div> ) |
