From 538f306104eb2dfd5844013c7f57864309349af5 Mon Sep 17 00:00:00 2001 From: Kiet <31864905+Kitenite@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:56:25 -0700 Subject: [PATCH 1/7] Patch vuln (#3120) --- apps/marketing/package.json | 2 +- bun.lock | 12 +++--------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/apps/marketing/package.json b/apps/marketing/package.json index fc4221af181..53d6ea3e2c4 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -25,7 +25,7 @@ "import-in-the-middle": "2.0.1", "lucide-react": "^0.563.0", "next": "^16.0.10", - "next-mdx-remote": "^5.0.0", + "next-mdx-remote": "^6.0.0", "next-themes": "^0.4.6", "posthog-js": "1.310.1", "react": "19.2.0", diff --git a/bun.lock b/bun.lock index 4d4a05c0bff..3601709f8d9 100644 --- a/bun.lock +++ b/bun.lock @@ -399,7 +399,7 @@ "import-in-the-middle": "2.0.1", "lucide-react": "^0.563.0", "next": "^16.0.10", - "next-mdx-remote": "^5.0.0", + "next-mdx-remote": "^6.0.0", "next-themes": "^0.4.6", "posthog-js": "1.310.1", "react": "19.2.0", @@ -4432,7 +4432,7 @@ "next": ["next@16.2.1", "", { "dependencies": { "@next/env": "16.2.1", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.1", "@next/swc-darwin-x64": "16.2.1", "@next/swc-linux-arm64-gnu": "16.2.1", "@next/swc-linux-arm64-musl": "16.2.1", "@next/swc-linux-x64-gnu": "16.2.1", "@next/swc-linux-x64-musl": "16.2.1", "@next/swc-win32-arm64-msvc": "16.2.1", "@next/swc-win32-x64-msvc": "16.2.1", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q=="], - "next-mdx-remote": ["next-mdx-remote@5.0.0", "", { "dependencies": { "@babel/code-frame": "^7.23.5", "@mdx-js/mdx": "^3.0.1", "@mdx-js/react": "^3.0.1", "unist-util-remove": "^3.1.0", "vfile": "^6.0.1", "vfile-matter": "^5.0.0" }, "peerDependencies": { "react": ">=16" } }, "sha512-RNNbqRpK9/dcIFZs/esQhuLA8jANqlH694yqoDBK8hkVdJUndzzGmnPHa2nyi90N4Z9VmzuSWNRpr5ItT3M7xQ=="], + "next-mdx-remote": ["next-mdx-remote@6.0.0", "", { "dependencies": { "@babel/code-frame": "^7.23.5", "@mdx-js/mdx": "^3.0.1", "@mdx-js/react": "^3.0.1", "unist-util-remove": "^4.0.0", "unist-util-visit": "^5.1.0", "vfile": "^6.0.1", "vfile-matter": "^5.0.0" }, "peerDependencies": { "react": ">=16" } }, "sha512-cJEpEZlgD6xGjB4jL8BnI8FaYdN9BzZM4NwadPe1YQr7pqoWjg9EBCMv3nXBkuHqMRfv2y33SzUsuyNh9LFAQQ=="], "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], @@ -5386,7 +5386,7 @@ "unist-util-position-from-estree": ["unist-util-position-from-estree@2.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ=="], - "unist-util-remove": ["unist-util-remove@3.1.1", "", { "dependencies": { "@types/unist": "^2.0.0", "unist-util-is": "^5.0.0", "unist-util-visit-parents": "^5.0.0" } }, "sha512-kfCqZK5YVY5yEa89tvpl7KnBBHu2c6CzMkqHUrlOqaRgGOMp0sMvwWOVrbAtj03KhovQB7i96Gda72v/EFE0vw=="], + "unist-util-remove": ["unist-util-remove@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-b4gokeGId57UVRX/eVKej5gXqGlc9+trkORhFJpu9raqZkZhU0zm8Doi05+HaiBsMEIJowL+2WtQ5ItjsngPXg=="], "unist-util-remove-position": ["unist-util-remove-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q=="], @@ -6328,12 +6328,6 @@ "tunnel-rat/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], - "unist-util-remove/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], - - "unist-util-remove/unist-util-is": ["unist-util-is@5.2.1", "", { "dependencies": { "@types/unist": "^2.0.0" } }, "sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw=="], - - "unist-util-remove/unist-util-visit-parents": ["unist-util-visit-parents@5.1.3", "", { "dependencies": { "@types/unist": "^2.0.0", "unist-util-is": "^5.0.0" } }, "sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg=="], - "uniwind/@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="], "uniwind/@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="], From 1588d20a69cc6314a7ffb6de9970bc2fdfccc076 Mon Sep 17 00:00:00 2001 From: Kiet <31864905+Kitenite@users.noreply.github.com> Date: Thu, 2 Apr 2026 10:29:49 -0700 Subject: [PATCH 2/7] feat(desktop): decouple terminal lifecycle from view lifecycle (#3108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * height * lifecycle * Deslop * Refactor * Separate terminal runtime from WebSocket transport in renderer Split terminal-runtime-registry.ts into three focused modules: - terminal-runtime.ts: XTerm instance, addons, DOM wrapper, buffer/dimension persistence, fit/resize - terminal-ws-transport.ts: WebSocket connection, message protocol, connection state - terminal-runtime-registry.ts: orchestrator mapping paneId to runtime + transport * Fix stale WebSocket handlers and eager runtime creation - Guard onClose/onError/onMessage with socket reference comparison to prevent displaced connections from corrupting active session state - Close old socket explicitly on reattach (code 4000) - Create registry entry eagerly in onStateChange so useSyncExternalStore listeners are registered before the attach effect runs * Co-locate useGlobalTerminalLifecycle under its component Move hook from routes/_authenticated/hooks/ into components/GlobalTerminalLifecycle/hooks/ since it's only used by that component. * Keep WebSocket alive across terminal attach/detach cycles detach() now only removes the DOM wrapper, resize observer, and focus — the WebSocket and xterm data flow stay alive so output written while the pane is hidden (tab switch, workspace switch) is not lost. attach() checks whether the transport is already open for the same URL and skips reconnection on simple re-shows, only reconnecting when the socket dropped or the endpoint changed. A full terminal.refresh() is added on re-attach to repaint rows that were written while the canvas was offscreen. * Lint * Move connect idempotency into transport layer connect() now early-returns when already open or connecting to the same URL, covering rapid tab-switch during an in-flight handshake. The registry no longer branches — it just calls connect() unconditionally. --- .../lib/terminal/terminal-runtime-registry.ts | 101 +++++++ .../renderer/lib/terminal/terminal-runtime.ts | 181 +++++++++++++ .../lib/terminal/terminal-ws-transport.ts | 148 +++++++++++ .../components/TerminalPane/TerminalPane.tsx | 176 +++---------- .../hooks/usePaneRegistry/usePaneRegistry.tsx | 4 +- .../GlobalTerminalLifecycle.tsx | 6 + .../hooks/useGlobalTerminalLifecycle/index.ts | 1 + .../useGlobalTerminalLifecycle.ts | 84 ++++++ .../GlobalTerminalLifecycle/index.ts | 1 + .../renderer/routes/_authenticated/layout.tsx | 2 + bun.lock | 11 + package.json | 13 +- .../host-service/src/terminal/terminal.ts | 246 ++++++++++++------ 13 files changed, 750 insertions(+), 224 deletions(-) create mode 100644 apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts create mode 100644 apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts create mode 100644 apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/GlobalTerminalLifecycle.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/hooks/useGlobalTerminalLifecycle/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/index.ts diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts new file mode 100644 index 00000000000..d6551f6b857 --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts @@ -0,0 +1,101 @@ +import { + attachToContainer, + createRuntime, + detachFromContainer, + disposeRuntime, + type TerminalRuntime, +} from "./terminal-runtime"; +import { + type ConnectionState, + connect, + createTransport, + disposeTransport, + sendDispose, + sendResize, + type TerminalTransport, +} from "./terminal-ws-transport"; + +interface RegistryEntry { + runtime: TerminalRuntime; + transport: TerminalTransport; +} + +class TerminalRuntimeRegistryImpl { + private entries = new Map(); + + private getOrCreate(paneId: string): RegistryEntry { + let entry = this.entries.get(paneId); + if (entry) return entry; + + entry = { + runtime: createRuntime(paneId), + transport: createTransport(), + }; + + this.entries.set(paneId, entry); + return entry; + } + + attach(paneId: string, container: HTMLDivElement, wsUrl: string) { + const { runtime, transport } = this.getOrCreate(paneId); + + attachToContainer(runtime, container, () => { + sendResize(transport, runtime.terminal.cols, runtime.terminal.rows); + }); + + connect(transport, runtime.terminal, wsUrl); + } + + /** + * Detach the terminal from its DOM container. + * + * This only removes the DOM attachment (wrapper, resize observer, focus). + * The WebSocket and xterm data flow are intentionally kept alive so output + * written while the pane is hidden is not lost. Disposal of the transport + * happens exclusively through {@link dispose} when the paneId is removed + * from persisted pane state. + */ + detach(paneId: string) { + const entry = this.entries.get(paneId); + if (!entry) return; + + detachFromContainer(entry.runtime); + } + + dispose(paneId: string) { + const entry = this.entries.get(paneId); + if (!entry) return; + + sendDispose(entry.transport); + disposeTransport(entry.transport); + disposeRuntime(entry.runtime); + + this.entries.delete(paneId); + } + + getAllPaneIds(): Set { + return new Set(this.entries.keys()); + } + + has(paneId: string): boolean { + return this.entries.has(paneId); + } + + getConnectionState(paneId: string): ConnectionState { + return ( + this.entries.get(paneId)?.transport.connectionState ?? "disconnected" + ); + } + + onStateChange(paneId: string, listener: () => void): () => void { + const { transport } = this.getOrCreate(paneId); + transport.stateListeners.add(listener); + return () => { + transport.stateListeners.delete(listener); + }; + } +} + +export const terminalRuntimeRegistry = new TerminalRuntimeRegistryImpl(); + +export type { ConnectionState }; diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts new file mode 100644 index 00000000000..484a44d635f --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts @@ -0,0 +1,181 @@ +import { FitAddon } from "@xterm/addon-fit"; +import { SerializeAddon } from "@xterm/addon-serialize"; +import { Terminal as XTerm } from "@xterm/xterm"; + +const SERIALIZE_SCROLLBACK = 1000; +const STORAGE_KEY_PREFIX = "terminal-buffer:"; +const DIMS_KEY_PREFIX = "terminal-dims:"; +const DEFAULT_COLS = 120; +const DEFAULT_ROWS = 32; + +export interface TerminalRuntime { + paneId: string; + terminal: XTerm; + fitAddon: FitAddon; + serializeAddon: SerializeAddon; + /** Reparented between containers across attach/detach cycles — not recreated. */ + wrapper: HTMLDivElement; + container: HTMLDivElement | null; + resizeObserver: ResizeObserver | null; + /** Fallback grid size used when the host is not visible. */ + lastCols: number; + lastRows: number; +} + +function createTerminal( + cols: number, + rows: number, +): { + terminal: XTerm; + fitAddon: FitAddon; + serializeAddon: SerializeAddon; +} { + const fitAddon = new FitAddon(); + const serializeAddon = new SerializeAddon(); + const terminal = new XTerm({ + cols, + rows, + cursorBlink: true, + fontFamily: + 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', + fontSize: 12, + theme: { + background: "#14100f", + foreground: "#f5efe9", + }, + }); + terminal.loadAddon(fitAddon); + terminal.loadAddon(serializeAddon); + return { terminal, fitAddon, serializeAddon }; +} + +function persistBuffer(paneId: string, serializeAddon: SerializeAddon) { + try { + const data = serializeAddon.serialize({ scrollback: SERIALIZE_SCROLLBACK }); + localStorage.setItem(`${STORAGE_KEY_PREFIX}${paneId}`, data); + } catch {} +} + +function restoreBuffer(paneId: string, terminal: XTerm) { + try { + const data = localStorage.getItem(`${STORAGE_KEY_PREFIX}${paneId}`); + if (data) terminal.write(data); + } catch {} +} + +function clearPersistedBuffer(paneId: string) { + try { + localStorage.removeItem(`${STORAGE_KEY_PREFIX}${paneId}`); + } catch {} +} + +function persistDimensions(paneId: string, cols: number, rows: number) { + try { + localStorage.setItem( + `${DIMS_KEY_PREFIX}${paneId}`, + JSON.stringify({ cols, rows }), + ); + } catch {} +} + +function loadSavedDimensions( + paneId: string, +): { cols: number; rows: number } | null { + try { + const raw = localStorage.getItem(`${DIMS_KEY_PREFIX}${paneId}`); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (typeof parsed.cols === "number" && typeof parsed.rows === "number") { + return parsed; + } + return null; + } catch { + return null; + } +} + +function clearPersistedDimensions(paneId: string) { + try { + localStorage.removeItem(`${DIMS_KEY_PREFIX}${paneId}`); + } catch {} +} + +function hostIsVisible(container: HTMLDivElement | null): boolean { + if (!container) return false; + return container.clientWidth > 0 && container.clientHeight > 0; +} + +function measureAndResize(runtime: TerminalRuntime) { + if (!hostIsVisible(runtime.container)) return; + runtime.fitAddon.fit(); + runtime.lastCols = runtime.terminal.cols; + runtime.lastRows = runtime.terminal.rows; +} + +export function createRuntime(paneId: string): TerminalRuntime { + const savedDims = loadSavedDimensions(paneId); + const cols = savedDims?.cols ?? DEFAULT_COLS; + const rows = savedDims?.rows ?? DEFAULT_ROWS; + + const { terminal, fitAddon, serializeAddon } = createTerminal(cols, rows); + + const wrapper = document.createElement("div"); + wrapper.style.width = "100%"; + wrapper.style.height = "100%"; + terminal.open(wrapper); + restoreBuffer(paneId, terminal); + + return { + paneId, + terminal, + fitAddon, + serializeAddon, + wrapper, + container: null, + resizeObserver: null, + lastCols: cols, + lastRows: rows, + }; +} + +export function attachToContainer( + runtime: TerminalRuntime, + container: HTMLDivElement, + onResize?: () => void, +) { + runtime.container = container; + container.appendChild(runtime.wrapper); + measureAndResize(runtime); + + // Force a full repaint — the renderer may have skipped paint frames while + // the wrapper was detached from the DOM and receiving background data. + runtime.terminal.refresh(0, runtime.terminal.rows - 1); + + runtime.resizeObserver?.disconnect(); + const observer = new ResizeObserver(() => { + measureAndResize(runtime); + onResize?.(); + }); + observer.observe(container); + runtime.resizeObserver = observer; + + runtime.terminal.focus(); +} + +export function detachFromContainer(runtime: TerminalRuntime) { + persistBuffer(runtime.paneId, runtime.serializeAddon); + persistDimensions(runtime.paneId, runtime.lastCols, runtime.lastRows); + runtime.resizeObserver?.disconnect(); + runtime.resizeObserver = null; + runtime.wrapper.remove(); + runtime.container = null; +} + +export function disposeRuntime(runtime: TerminalRuntime) { + runtime.resizeObserver?.disconnect(); + runtime.resizeObserver = null; + runtime.wrapper.remove(); + runtime.terminal.dispose(); + clearPersistedBuffer(runtime.paneId); + clearPersistedDimensions(runtime.paneId); +} diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts new file mode 100644 index 00000000000..e522a0b8304 --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts @@ -0,0 +1,148 @@ +import type { Terminal as XTerm } from "@xterm/xterm"; + +export type ConnectionState = "disconnected" | "connecting" | "open" | "closed"; + +type TerminalServerMessage = + | { type: "data"; data: string } + | { type: "error"; message: string } + | { type: "exit"; exitCode: number; signal: number } + | { type: "replay"; data: string }; + +export interface TerminalTransport { + socket: WebSocket | null; + connectionState: ConnectionState; + /** The URL the socket is currently connected (or connecting) to. */ + currentUrl: string | null; + onDataDisposable: { dispose(): void } | null; + stateListeners: Set<() => void>; +} + +function setConnectionState( + transport: TerminalTransport, + state: ConnectionState, +) { + transport.connectionState = state; + for (const listener of transport.stateListeners) { + listener(); + } +} + +export function createTransport(): TerminalTransport { + return { + socket: null, + connectionState: "disconnected", + currentUrl: null, + onDataDisposable: null, + stateListeners: new Set(), + }; +} + +export function connect( + transport: TerminalTransport, + terminal: XTerm, + wsUrl: string, +) { + // Idempotent: skip if already connected/connecting to the same endpoint. + const isActive = + transport.connectionState === "open" || + transport.connectionState === "connecting"; + if (isActive && transport.currentUrl === wsUrl) return; + + if (transport.socket) { + transport.socket.close(); + transport.socket = null; + } + + transport.currentUrl = wsUrl; + setConnectionState(transport, "connecting"); + const socket = new WebSocket(wsUrl); + transport.socket = socket; + + socket.addEventListener("open", () => { + if (transport.socket !== socket) return; + setConnectionState(transport, "open"); + sendResize(transport, terminal.cols, terminal.rows); + }); + + socket.addEventListener("message", (event) => { + if (transport.socket !== socket) return; + let message: TerminalServerMessage; + try { + message = JSON.parse(String(event.data)) as TerminalServerMessage; + } catch { + terminal.writeln("\r\n[terminal] invalid server payload"); + return; + } + + if (message.type === "data" || message.type === "replay") { + terminal.write(message.data); + return; + } + + if (message.type === "error") { + terminal.writeln(`\r\n[terminal] ${message.message}`); + return; + } + + if (message.type === "exit") { + terminal.writeln( + `\r\n[terminal] exited with code ${message.exitCode} (signal ${message.signal})`, + ); + } + }); + + socket.addEventListener("close", () => { + if (transport.socket !== socket) return; + setConnectionState(transport, "closed"); + transport.socket = null; + }); + + socket.addEventListener("error", () => { + if (transport.socket !== socket) return; + terminal.writeln("\r\n[terminal] websocket error"); + }); + + transport.onDataDisposable?.dispose(); + transport.onDataDisposable = terminal.onData((data) => { + if (socket.readyState !== WebSocket.OPEN) return; + socket.send(JSON.stringify({ type: "input", data })); + }); +} + +export function disconnect(transport: TerminalTransport) { + if (transport.socket) { + transport.socket.close(); + transport.socket = null; + } + transport.currentUrl = null; + setConnectionState(transport, "disconnected"); + transport.onDataDisposable?.dispose(); + transport.onDataDisposable = null; +} + +export function sendResize( + transport: TerminalTransport, + cols: number, + rows: number, +) { + if (!transport.socket || transport.socket.readyState !== WebSocket.OPEN) + return; + transport.socket.send(JSON.stringify({ type: "resize", cols, rows })); +} + +export function sendDispose(transport: TerminalTransport) { + if (transport.socket?.readyState === WebSocket.OPEN) { + transport.socket.send(JSON.stringify({ type: "dispose" })); + } +} + +export function disposeTransport(transport: TerminalTransport) { + if (transport.socket) { + transport.socket.close(); + transport.socket = null; + } + transport.currentUrl = null; + transport.onDataDisposable?.dispose(); + transport.onDataDisposable = null; + transport.stateListeners.clear(); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx index 5df9f5293ec..dffa3e4810d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx @@ -1,168 +1,58 @@ -import { Button } from "@superset/ui/button"; -import { FitAddon } from "@xterm/addon-fit"; -import { Terminal as XTerm } from "@xterm/xterm"; import "@xterm/xterm/css/xterm.css"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useSyncExternalStore } from "react"; +import { + type ConnectionState, + terminalRuntimeRegistry, +} from "renderer/lib/terminal/terminal-runtime-registry"; import { useWorkspaceWsUrl } from "../../../../../providers/WorkspaceTrpcProvider/WorkspaceTrpcProvider"; -interface WorkspaceTerminalProps { +interface TerminalPaneProps { + paneId: string; workspaceId: string; } -type TerminalServerMessage = - | { - type: "data"; - data: string; - } - | { - type: "error"; - message: string; - } - | { - type: "exit"; - exitCode: number; - signal: number; - }; +function subscribeToState(paneId: string) { + return (callback: () => void) => + terminalRuntimeRegistry.onStateChange(paneId, callback); +} + +function getConnectionState(paneId: string): ConnectionState { + return terminalRuntimeRegistry.getConnectionState(paneId); +} -export function TerminalPane({ workspaceId }: WorkspaceTerminalProps) { +export function TerminalPane({ paneId, workspaceId }: TerminalPaneProps) { const containerRef = useRef(null); - const [connectionState, setConnectionState] = useState< - "connecting" | "open" | "closed" - >("connecting"); - const [reconnectKey, setReconnectKey] = useState(0); - const websocketUrl = useWorkspaceWsUrl(`/terminal/${workspaceId}`, { - reconnect: String(reconnectKey), + const websocketUrl = useWorkspaceWsUrl(`/terminal/${paneId}`, { + workspaceId, }); + const connectionState = useSyncExternalStore(subscribeToState(paneId), () => + getConnectionState(paneId), + ); + useEffect(() => { const container = containerRef.current; - if (!container) { - return; - } - - const fitAddon = new FitAddon(); - const terminal = new XTerm({ - cursorBlink: true, - fontFamily: - 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', - fontSize: 12, - theme: { - background: "#14100f", - foreground: "#f5efe9", - }, - }); - terminal.loadAddon(fitAddon); - terminal.open(container); - fitAddon.fit(); - terminal.focus(); - - setConnectionState("connecting"); - const socket = new WebSocket(websocketUrl); - - const sendResize = () => { - if (socket.readyState !== WebSocket.OPEN) { - return; - } - - socket.send( - JSON.stringify({ - type: "resize", - cols: terminal.cols, - rows: terminal.rows, - }), - ); - }; - - const resizeObserver = new ResizeObserver(() => { - fitAddon.fit(); - sendResize(); - }); - resizeObserver.observe(container); + if (!container) return; - const onTerminalDataDispose = terminal.onData((data) => { - if (socket.readyState !== WebSocket.OPEN) { - return; - } - - socket.send( - JSON.stringify({ - type: "input", - data, - }), - ); - }); - - socket.addEventListener("open", () => { - setConnectionState("open"); - sendResize(); - }); - - socket.addEventListener("message", (event) => { - let message: TerminalServerMessage; - try { - message = JSON.parse(String(event.data)) as TerminalServerMessage; - } catch { - terminal.writeln("\r\n[terminal] invalid server payload"); - return; - } - - if (message.type === "data") { - terminal.write(message.data); - return; - } - - if (message.type === "error") { - terminal.writeln(`\r\n[terminal] ${message.message}`); - return; - } - - terminal.writeln( - `\r\n[terminal] exited with code ${message.exitCode} (signal ${message.signal})`, - ); - }); - - socket.addEventListener("close", () => { - setConnectionState("closed"); - }); - - socket.addEventListener("error", () => { - terminal.writeln("\r\n[terminal] websocket error"); - }); + terminalRuntimeRegistry.attach(paneId, container, websocketUrl); return () => { - resizeObserver.disconnect(); - onTerminalDataDispose.dispose(); - socket.close(); - terminal.dispose(); + terminalRuntimeRegistry.detach(paneId); }; - }, [websocketUrl]); + }, [paneId, websocketUrl]); return ( -
-
-
-

terminal

-

- {connectionState === "open" - ? "Connected" - : connectionState === "connecting" - ? "Connecting..." - : "Disconnected"} -

-
- -
+
+ {connectionState === "closed" && ( +
+ Disconnected +
+ )}
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index 2d9e5373828..d8331c359b8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -40,7 +40,9 @@ export function usePaneRegistry( terminal: { getIcon: () => , getTitle: () => "Terminal", - renderPane: () => , + renderPane: (ctx: RendererContext) => ( + + ), }, browser: { getIcon: () => , diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/GlobalTerminalLifecycle.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/GlobalTerminalLifecycle.tsx new file mode 100644 index 00000000000..a5fe4668d22 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/GlobalTerminalLifecycle.tsx @@ -0,0 +1,6 @@ +import { useGlobalTerminalLifecycle } from "./hooks/useGlobalTerminalLifecycle"; + +export function GlobalTerminalLifecycle() { + useGlobalTerminalLifecycle(); + return null; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/hooks/useGlobalTerminalLifecycle/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/hooks/useGlobalTerminalLifecycle/index.ts new file mode 100644 index 00000000000..42a2df8af11 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/hooks/useGlobalTerminalLifecycle/index.ts @@ -0,0 +1 @@ +export { useGlobalTerminalLifecycle } from "./useGlobalTerminalLifecycle"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts new file mode 100644 index 00000000000..7a3e528c301 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts @@ -0,0 +1,84 @@ +import type { WorkspaceState } from "@superset/panes"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useEffect, useRef } from "react"; +import { terminalRuntimeRegistry } from "renderer/lib/terminal/terminal-runtime-registry"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; + +/** Cross-workspace moves temporarily remove a paneId then re-add it. Wait before disposing. */ +const DISPOSE_DELAY_MS = 500; + +function extractTerminalPaneIds(rows: { paneLayout: unknown }[]): Set { + const ids = new Set(); + for (const row of rows) { + const layout = row.paneLayout as WorkspaceState | undefined; + if (!layout?.tabs) continue; + for (const tab of layout.tabs) { + for (const [paneId, pane] of Object.entries(tab.panes)) { + if (pane.kind === "terminal") { + ids.add(paneId); + } + } + } + } + return ids; +} + +export function useGlobalTerminalLifecycle() { + const collections = useCollections(); + const prevPaneIdsRef = useRef>(new Set()); + const pendingDisposals = useRef>>( + new Map(), + ); + + const { data: allWorkspaceRows = [] } = useLiveQuery( + (query) => + query.from({ + v2WorkspaceLocalState: collections.v2WorkspaceLocalState, + }), + [collections], + ); + + useEffect(() => { + const currentPaneIds = extractTerminalPaneIds(allWorkspaceRows); + const prevPaneIds = prevPaneIdsRef.current; + + for (const paneId of currentPaneIds) { + const timer = pendingDisposals.current.get(paneId); + if (timer) { + clearTimeout(timer); + pendingDisposals.current.delete(paneId); + } + } + + for (const paneId of prevPaneIds) { + if (currentPaneIds.has(paneId)) continue; + if (pendingDisposals.current.has(paneId)) continue; + + const timer = setTimeout(() => { + pendingDisposals.current.delete(paneId); + + const freshRows = Array.from( + collections.v2WorkspaceLocalState.state.values(), + ); + const freshIds = extractTerminalPaneIds(freshRows); + + if (!freshIds.has(paneId)) { + terminalRuntimeRegistry.dispose(paneId); + } + }, DISPOSE_DELAY_MS); + + pendingDisposals.current.set(paneId, timer); + } + + prevPaneIdsRef.current = currentPaneIds; + }, [allWorkspaceRows, collections]); + + useEffect(() => { + return () => { + for (const timer of pendingDisposals.current.values()) { + clearTimeout(timer); + } + pendingDisposals.current.clear(); + }; + }, []); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/index.ts new file mode 100644 index 00000000000..d2ed752bdc2 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/index.ts @@ -0,0 +1 @@ +export { GlobalTerminalLifecycle } from "./GlobalTerminalLifecycle"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx index 8083d6b0dec..fdf03790792 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx @@ -32,6 +32,7 @@ import { setPaneWorkspaceRunState } from "renderer/stores/tabs/workspace-run"; import { useWorkspaceInitStore } from "renderer/stores/workspace-init"; import { MOCK_ORG_ID, NOTIFICATION_EVENTS } from "shared/constants"; import { AgentHooks } from "./components/AgentHooks"; +import { GlobalTerminalLifecycle } from "./components/GlobalTerminalLifecycle"; import { TeardownLogsDialog } from "./components/TeardownLogsDialog"; import { CollectionsProvider } from "./providers/CollectionsProvider"; import { HostServiceProvider } from "./providers/HostServiceProvider"; @@ -174,6 +175,7 @@ function AuthenticatedLayout() { return ( + diff --git a/bun.lock b/bun.lock index 3601709f8d9..311e3ba6138 100644 --- a/bun.lock +++ b/bun.lock @@ -932,6 +932,17 @@ "version": "0.1.0", }, }, + "trustedDependencies": [ + "koffi", + "sharp", + "node-pty", + "utf-8-validate", + "esbuild", + "electron", + "better-sqlite3", + "bufferutil", + "workerd", + ], "overrides": { "axios": "1.14.0", }, diff --git a/package.json b/package.json index 02b99f62842..2cef5488f9f 100644 --- a/package.json +++ b/package.json @@ -50,5 +50,16 @@ }, "patchedDependencies": { "@durable-streams/state@0.2.1": "patches/@durable-streams%2Fstate@0.2.1.patch" - } + }, + "trustedDependencies": [ + "better-sqlite3", + "bufferutil", + "electron", + "esbuild", + "koffi", + "node-pty", + "sharp", + "utf-8-validate", + "workerd" + ] } diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts index 3e50c23c704..1e917419791 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -14,41 +14,41 @@ interface RegisterWorkspaceTerminalRouteOptions { } type TerminalClientMessage = - | { - type: "input"; - data: string; - } - | { - type: "resize"; - cols: number; - rows: number; - }; + | { type: "input"; data: string } + | { type: "resize"; cols: number; rows: number } + | { type: "dispose" }; type TerminalServerMessage = - | { - type: "data"; - data: string; - } - | { - type: "error"; - message: string; - } - | { - type: "exit"; - exitCode: number; - signal: number; - }; + | { type: "data"; data: string } + | { type: "error"; message: string } + | { type: "exit"; exitCode: number; signal: number } + | { type: "replay"; data: string }; -function sendMessage( +const MAX_BUFFER_BYTES = 64 * 1024; + +interface TerminalSession { + paneId: string; + pty: IPty; socket: { send: (data: string) => void; + close: (code?: number, reason?: string) => void; readyState: number; - }, + } | null; + buffer: string[]; + bufferBytes: number; + exited: boolean; + exitCode: number; + exitSignal: number; +} + +/** PTY lifetime is independent of socket lifetime — sockets detach/reattach freely. */ +const sessions = new Map(); + +function sendMessage( + socket: { send: (data: string) => void; readyState: number }, message: TerminalServerMessage, ) { - if (socket.readyState !== 1) { - return; - } + if (socket.readyState !== 1) return; socket.send(JSON.stringify(message)); } @@ -56,44 +56,97 @@ function resolveShell(): string { if (process.platform === "win32") { return process.env.COMSPEC || "cmd.exe"; } - return process.env.SHELL || "/bin/zsh"; } +function bufferOutput(session: TerminalSession, data: string) { + session.buffer.push(data); + session.bufferBytes += data.length; + + while (session.bufferBytes > MAX_BUFFER_BYTES && session.buffer.length > 1) { + const removed = session.buffer.shift(); + if (removed) session.bufferBytes -= removed.length; + } +} + +function replayBuffer( + session: TerminalSession, + socket: { send: (data: string) => void; readyState: number }, +) { + if (session.buffer.length === 0) return; + const combined = session.buffer.join(""); + session.buffer.length = 0; + session.bufferBytes = 0; + sendMessage(socket, { type: "replay", data: combined }); +} + +function disposeSession(paneId: string) { + const session = sessions.get(paneId); + if (!session) return; + + if (!session.exited) { + try { + session.pty.kill(); + } catch { + // PTY may already be dead + } + } + sessions.delete(paneId); +} + export function registerWorkspaceTerminalRoute({ app, db, upgradeWebSocket, }: RegisterWorkspaceTerminalRouteOptions) { app.get( - "/terminal/:workspaceId", + "/terminal/:paneId", upgradeWebSocket((c) => { - const workspaceId = c.req.param("workspaceId"); - const workspace = workspaceId - ? db.query.workspaces - .findFirst({ where: eq(workspaces.id, workspaceId) }) - .sync() - : null; - - let terminal: IPty | null = null; - let disposed = false; - - const disposeTerminal = () => { - if (disposed) { - return; - } - disposed = true; - terminal?.kill(); - terminal = null; - }; + const paneId = c.req.param("paneId"); + const workspaceId = c.req.query("workspaceId") ?? null; return { onOpen: (_event, ws) => { - if ( - !workspaceId || - !workspace || - !existsSync(workspace.worktreePath) - ) { + if (!paneId) { + sendMessage(ws, { + type: "error", + message: "Missing paneId", + }); + ws.close(1011, "Missing paneId"); + return; + } + + const existing = sessions.get(paneId); + if (existing) { + if (existing.socket && existing.socket !== ws) { + existing.socket.close(4000, "Displaced by new connection"); + } + existing.socket = ws; + replayBuffer(existing, ws); + if (existing.exited) { + sendMessage(ws, { + type: "exit", + exitCode: existing.exitCode, + signal: existing.exitSignal, + }); + } + return; + } + + if (!workspaceId) { + sendMessage(ws, { + type: "error", + message: "Missing workspaceId for new terminal session", + }); + ws.close(1011, "Missing workspaceId"); + return; + } + + const workspace = db.query.workspaces + .findFirst({ where: eq(workspaces.id, workspaceId) }) + .sync(); + + if (!workspace || !existsSync(workspace.worktreePath)) { sendMessage(ws, { type: "error", message: "Workspace worktree not found", @@ -102,8 +155,9 @@ export function registerWorkspaceTerminalRoute({ return; } + let pty: IPty; try { - terminal = spawn(resolveShell(), [], { + pty = spawn(resolveShell(), [], { name: "xterm-256color", cwd: workspace.worktreePath, cols: 120, @@ -128,55 +182,89 @@ export function registerWorkspaceTerminalRoute({ return; } - terminal.onData((data) => { - sendMessage(ws, { - type: "data", - data, - }); + const session: TerminalSession = { + paneId, + pty, + socket: ws, + buffer: [], + bufferBytes: 0, + exited: false, + exitCode: 0, + exitSignal: 0, + }; + sessions.set(paneId, session); + + pty.onData((data) => { + if (session.socket?.readyState === 1) { + sendMessage(session.socket, { type: "data", data }); + } else { + bufferOutput(session, data); + } }); - terminal.onExit(({ exitCode, signal }) => { - sendMessage(ws, { - type: "exit", - exitCode: exitCode ?? 0, - signal: signal ?? 0, - }); - ws.close(1000, "Terminal exited"); - disposeTerminal(); + pty.onExit(({ exitCode, signal }) => { + session.exited = true; + session.exitCode = exitCode ?? 0; + session.exitSignal = signal ?? 0; + + if (session.socket?.readyState === 1) { + sendMessage(session.socket, { + type: "exit", + exitCode: session.exitCode, + signal: session.exitSignal, + }); + } }); }, + onMessage: (event, ws) => { - if (!terminal) { - return; - } + const session = sessions.get(paneId ?? ""); + if (!session || session.socket !== ws) return; let message: TerminalClientMessage; try { message = JSON.parse(String(event.data)) as TerminalClientMessage; } catch { - sendMessage(ws, { - type: "error", - message: "Invalid terminal message payload", - }); + if (session.socket) { + sendMessage(session.socket, { + type: "error", + message: "Invalid terminal message payload", + }); + } + return; + } + + if (message.type === "dispose") { + disposeSession(paneId ?? ""); return; } + if (session.exited) return; + if (message.type === "input") { - terminal.write(message.data); + session.pty.write(message.data); return; } if (message.type === "resize") { const cols = Math.max(20, Math.floor(message.cols)); const rows = Math.max(5, Math.floor(message.rows)); - terminal.resize(cols, rows); + session.pty.resize(cols, rows); } }, - onClose: () => { - disposeTerminal(); + + onClose: (_event, ws) => { + const session = sessions.get(paneId ?? ""); + if (session?.socket === ws) { + session.socket = null; + } }, - onError: () => { - disposeTerminal(); + + onError: (_event, ws) => { + const session = sessions.get(paneId ?? ""); + if (session?.socket === ws) { + session.socket = null; + } }, }; }), From 59426f601511cffba6fac5c392be73755e84bd03 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 2 Apr 2026 10:41:34 -0700 Subject: [PATCH 3/7] feat(desktop): file tree sidebar, file pane renderers, and alert refactor (#3122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(desktop): file tree sidebar, file pane, and alert refactor - Add RightSidebar with file tree, toggled via Cmd+L, state persisted in workspace local collection - Create FilePane with CodeRenderer (Monaco), MarkdownRenderer (TipTap), and ImageRenderer — replaces WorkspaceFilePreview - File-type icons, italic title when unpinned, dirty indicator dot - onBeforeClose on PaneDefinition — registry-level close guards with Save/Don't Save/Cancel dialog - onHeaderClick on PaneDefinition — click header to pin, middle-click to close - selectedFilePath derived from store, sidebar highlights active file - Click file in sidebar: pin if already active, else open unpinned - Cmd+O wired up in OpenInMenuButton for both old and v2 workspace - setPanePinned: removed unnecessary tabId param - useFileTree: added reveal() method, fixed stale cache (staleTime: 0) - Alert component: refactored from onConfirm/onCancel to actions array API, migrated all 8 call sites * fix: scroll revealed file to center of sidebar * fix: remove unused filePath param from MarkdownRenderer --- ...seDashboardSidebarProjectSectionActions.ts | 12 +- .../OpenInMenuButton/OpenInMenuButton.tsx | 6 +- .../RightSidebar/RightSidebar.tsx} | 134 ++++++++------- .../WorkspaceFilesSearchResultItem.tsx | 0 .../WorkspaceFilesSearchResultItem/index.ts | 0 .../WorkspaceFilesToolbar.tsx | 0 .../components/WorkspaceFilesToolbar/index.ts | 0 .../WorkspaceFilesTreeItem.tsx | 1 + .../WorkspaceFilesTreeItem/index.ts | 0 .../hooks/useWorkspaceFileSearch/index.ts | 0 .../useWorkspaceFileSearch.ts | 0 .../components/RightSidebar/index.ts | 1 + .../SessionSelectorItem.tsx | 24 ++- .../components/FilePane/FilePane.tsx | 106 ++++++++++++ .../ExternalChangeBar/ExternalChangeBar.tsx | 18 ++ .../components/ExternalChangeBar/index.ts | 1 + .../components/FilePane/index.ts | 1 + .../renderers/CodeRenderer/CodeRenderer.tsx | 59 +++++++ .../FilePane/renderers/CodeRenderer/index.ts | 1 + .../renderers/ImageRenderer/ImageRenderer.tsx | 30 ++++ .../FilePane/renderers/ImageRenderer/index.ts | 1 + .../MarkdownRenderer/MarkdownRenderer.tsx | 97 +++++++++++ .../renderers/MarkdownRenderer/index.ts | 5 + .../WorkspaceFilePreview.tsx | 26 --- .../WorkspaceFilePreviewContent.tsx | 89 ---------- .../WorkspaceFilePreviewContent/index.ts | 1 - .../components/WorkspaceFilePreview/index.ts | 1 - .../components/FilesPane/index.ts | 1 - .../hooks/usePaneRegistry/usePaneRegistry.tsx | 64 +++++-- .../v2-workspace/$workspaceId/page.tsx | 157 ++++++++++++++---- .../dashboardSidebarLocal/schema.ts | 1 + .../ApiKeysSettings/ApiKeysSettings.tsx | 18 +- .../InviteMemberButton/InviteMemberButton.tsx | 7 +- .../MemberActions/MemberActions.tsx | 30 ++-- .../AddSecretSheet/AddSecretSheet.tsx | 12 +- .../SessionSelectorItem.tsx | 24 ++- packages/panes/src/core/store/store.test.ts | 1 - packages/panes/src/core/store/store.ts | 45 +++-- .../components/Tab/components/Pane/Pane.tsx | 16 +- .../Pane/components/PaneHeader/PaneHeader.tsx | 13 ++ packages/panes/src/react/types.ts | 2 + packages/ui/src/atoms/Alert/Alert.tsx | 103 ++++++------ packages/ui/src/atoms/Alert/index.ts | 1 + .../src/hooks/useFileTree/useFileTree.ts | 31 +++- 44 files changed, 787 insertions(+), 353 deletions(-) rename apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/{hooks/usePaneRegistry/components/FilesPane/FilesPane.tsx => components/RightSidebar/RightSidebar.tsx} (55%) rename apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/{hooks/usePaneRegistry/components/FilesPane => components/RightSidebar}/components/WorkspaceFilesSearchResultItem/WorkspaceFilesSearchResultItem.tsx (100%) rename apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/{hooks/usePaneRegistry/components/FilesPane => components/RightSidebar}/components/WorkspaceFilesSearchResultItem/index.ts (100%) rename apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/{hooks/usePaneRegistry/components/FilesPane => components/RightSidebar}/components/WorkspaceFilesToolbar/WorkspaceFilesToolbar.tsx (100%) rename apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/{hooks/usePaneRegistry/components/FilesPane => components/RightSidebar}/components/WorkspaceFilesToolbar/index.ts (100%) rename apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/{hooks/usePaneRegistry/components/FilesPane => components/RightSidebar}/components/WorkspaceFilesTreeItem/WorkspaceFilesTreeItem.tsx (97%) rename apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/{hooks/usePaneRegistry/components/FilesPane => components/RightSidebar}/components/WorkspaceFilesTreeItem/index.ts (100%) rename apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/{hooks/usePaneRegistry/components/FilesPane => components/RightSidebar}/hooks/useWorkspaceFileSearch/index.ts (100%) rename apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/{hooks/usePaneRegistry/components/FilesPane => components/RightSidebar}/hooks/useWorkspaceFileSearch/useWorkspaceFileSearch.ts (100%) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/RightSidebar/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ExternalChangeBar/ExternalChangeBar.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ExternalChangeBar/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/CodeRenderer/CodeRenderer.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/CodeRenderer/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/ImageRenderer/ImageRenderer.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/ImageRenderer/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/MarkdownRenderer/MarkdownRenderer.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/MarkdownRenderer/index.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilesPane/components/WorkspaceFilePreview/WorkspaceFilePreview.tsx delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilesPane/components/WorkspaceFilePreview/components/WorkspaceFilePreviewContent/WorkspaceFilePreviewContent.tsx delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilesPane/components/WorkspaceFilePreview/components/WorkspaceFilePreviewContent/index.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilesPane/components/WorkspaceFilePreview/index.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilesPane/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/hooks/useDashboardSidebarProjectSectionActions/useDashboardSidebarProjectSectionActions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/hooks/useDashboardSidebarProjectSectionActions/useDashboardSidebarProjectSectionActions.ts index 833ee1f70a4..f2804a3ac85 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/hooks/useDashboardSidebarProjectSectionActions/useDashboardSidebarProjectSectionActions.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/hooks/useDashboardSidebarProjectSectionActions/useDashboardSidebarProjectSectionActions.ts @@ -61,12 +61,18 @@ export function useDashboardSidebarProjectSectionActions({ }; const confirmRemoveFromSidebar = () => { - alert.destructive({ + alert({ title: "Remove project from sidebar?", description: "This will remove workspaces from the sidebar and delete all project sections. The workspaces or projects won't be deleted.", - confirmText: "Remove", - onConfirm: () => removeProjectFromSidebar(project.id), + actions: [ + { label: "Cancel", variant: "outline", onClick: () => {} }, + { + label: "Remove", + variant: "destructive", + onClick: () => removeProjectFromSidebar(project.id), + }, + ], }); }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/OpenInMenuButton/OpenInMenuButton.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/OpenInMenuButton/OpenInMenuButton.tsx index 30e12cf88d9..f47735297b2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/OpenInMenuButton/OpenInMenuButton.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/OpenInMenuButton/OpenInMenuButton.tsx @@ -17,7 +17,7 @@ import { } from "renderer/components/OpenInExternalDropdown"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { useThemeStore } from "renderer/stores"; -import { useHotkeyText } from "renderer/stores/hotkeys"; +import { useAppHotkey, useHotkeyText } from "renderer/stores/hotkeys"; interface OpenInMenuButtonProps { worktreePath: string; @@ -80,6 +80,10 @@ export const OpenInMenuButton = memo(function OpenInMenuButton({ copyPath.mutate(worktreePath); }, [worktreePath, copyPath, openInApp.isPending]); + useAppHotkey("OPEN_IN_APP", handleOpenInEditor, undefined, [ + handleOpenInEditor, + ]); + return (
{/* Main button - opens in last used app */} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilesPane/FilesPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/RightSidebar/RightSidebar.tsx similarity index 55% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilesPane/FilesPane.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/RightSidebar/RightSidebar.tsx index e507048180a..b51e8c0a55a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilesPane/FilesPane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/RightSidebar/RightSidebar.tsx @@ -4,28 +4,27 @@ import { useWorkspaceFsEvents, workspaceTrpc, } from "@superset/workspace-client"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ROW_HEIGHT, TREE_INDENT, } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/constants"; -import { WorkspaceFilePreview } from "./components/WorkspaceFilePreview"; import { WorkspaceFilesSearchResultItem } from "./components/WorkspaceFilesSearchResultItem"; import { WorkspaceFilesToolbar } from "./components/WorkspaceFilesToolbar"; import { WorkspaceFilesTreeItem } from "./components/WorkspaceFilesTreeItem"; import { useWorkspaceFileSearch } from "./hooks/useWorkspaceFileSearch"; -interface WorkspaceFilesProps { +interface RightSidebarProps { onSelectFile: (absolutePath: string) => void; selectedFilePath?: string; workspaceId: string; } -export function FilesPane({ +export function RightSidebar({ onSelectFile, selectedFilePath, workspaceId, -}: WorkspaceFilesProps) { +}: RightSidebarProps) { const [isRefreshing, setIsRefreshing] = useState(false); const [searchTerm, setSearchTerm] = useState(""); const utils = workspaceTrpc.useUtils(); @@ -58,12 +57,32 @@ export function FilesPane({ if (searchTerm.trim().length === 0) { return; } - void utils.filesystem.searchFiles.invalidate(); }, Boolean(workspaceId && searchTerm.trim().length > 0), ); + const scrollContainerRef = useRef(null); + const prevSelectedRef = useRef(selectedFilePath); + + useEffect(() => { + if ( + selectedFilePath && + selectedFilePath !== prevSelectedRef.current && + rootPath + ) { + void fileTree.reveal(selectedFilePath).then(() => { + requestAnimationFrame(() => { + const el = scrollContainerRef.current?.querySelector( + `[data-filepath="${CSS.escape(selectedFilePath)}"]`, + ); + el?.scrollIntoView({ block: "center" }); + }); + }); + } + prevSelectedRef.current = selectedFilePath; + }, [selectedFilePath, rootPath, fileTree]); + const flattenedTreeEntries = useMemo(() => { const entries: Array<{ depth: number; @@ -112,68 +131,63 @@ export function FilesPane({ } return ( -
-
- {}} - onNewFolder={() => {}} - onRefresh={() => void handleRefresh()} - onSearchChange={setSearchTerm} - searchTerm={searchTerm} - /> -
- {hasQuery ? ( - searchResults.length === 0 ? ( -
- {isFetchingSearch ? "Searching files..." : "No matches found"} -
- ) : ( -
- {searchResults.map((entry) => ( - - ))} -
- ) - ) : fileTree.isLoadingRoot && fileTree.rootEntries.length === 0 ? ( +
+ {}} + onNewFolder={() => {}} + onRefresh={() => void handleRefresh()} + onSearchChange={setSearchTerm} + searchTerm={searchTerm} + /> +
+ {hasQuery ? ( + searchResults.length === 0 ? (
- Loading files... -
- ) : fileTree.rootEntries.length === 0 ? ( -
- No files found + {isFetchingSearch ? "Searching files..." : "No matches found"}
) : (
- {flattenedTreeEntries.map(({ depth, node }) => ( - - void fileTree.toggle(absolutePath) - } - rowHeight={ROW_HEIGHT} + {searchResults.map((entry) => ( + ))}
- )} -
-
-
- + ) + ) : fileTree.isLoadingRoot && fileTree.rootEntries.length === 0 ? ( +
+ Loading files... +
+ ) : fileTree.rootEntries.length === 0 ? ( +
+ No files found +
+ ) : ( +
+ {flattenedTreeEntries.map(({ depth, node }) => ( + + void fileTree.toggle(absolutePath) + } + rowHeight={ROW_HEIGHT} + selectedFilePath={selectedFilePath} + /> + ))} +
+ )}
); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilesPane/components/WorkspaceFilesSearchResultItem/WorkspaceFilesSearchResultItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/RightSidebar/components/WorkspaceFilesSearchResultItem/WorkspaceFilesSearchResultItem.tsx similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilesPane/components/WorkspaceFilesSearchResultItem/WorkspaceFilesSearchResultItem.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/RightSidebar/components/WorkspaceFilesSearchResultItem/WorkspaceFilesSearchResultItem.tsx diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilesPane/components/WorkspaceFilesSearchResultItem/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/RightSidebar/components/WorkspaceFilesSearchResultItem/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilesPane/components/WorkspaceFilesSearchResultItem/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/RightSidebar/components/WorkspaceFilesSearchResultItem/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilesPane/components/WorkspaceFilesToolbar/WorkspaceFilesToolbar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/RightSidebar/components/WorkspaceFilesToolbar/WorkspaceFilesToolbar.tsx similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilesPane/components/WorkspaceFilesToolbar/WorkspaceFilesToolbar.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/RightSidebar/components/WorkspaceFilesToolbar/WorkspaceFilesToolbar.tsx diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilesPane/components/WorkspaceFilesToolbar/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/RightSidebar/components/WorkspaceFilesToolbar/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilesPane/components/WorkspaceFilesToolbar/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/RightSidebar/components/WorkspaceFilesToolbar/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilesPane/components/WorkspaceFilesTreeItem/WorkspaceFilesTreeItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/RightSidebar/components/WorkspaceFilesTreeItem/WorkspaceFilesTreeItem.tsx similarity index 97% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilesPane/components/WorkspaceFilesTreeItem/WorkspaceFilesTreeItem.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/RightSidebar/components/WorkspaceFilesTreeItem/WorkspaceFilesTreeItem.tsx index 0e4b85c709e..d6bcdb41adf 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilesPane/components/WorkspaceFilesTreeItem/WorkspaceFilesTreeItem.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/RightSidebar/components/WorkspaceFilesTreeItem/WorkspaceFilesTreeItem.tsx @@ -27,6 +27,7 @@ export function WorkspaceFilesTreeItem({ return ( +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ExternalChangeBar/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ExternalChangeBar/index.ts new file mode 100644 index 00000000000..fac487bd149 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ExternalChangeBar/index.ts @@ -0,0 +1 @@ +export { ExternalChangeBar } from "./ExternalChangeBar"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/index.ts new file mode 100644 index 00000000000..bf2e051559b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/index.ts @@ -0,0 +1 @@ +export { FilePane } from "./FilePane"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/CodeRenderer/CodeRenderer.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/CodeRenderer/CodeRenderer.tsx new file mode 100644 index 00000000000..0f896fdf4e0 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/CodeRenderer/CodeRenderer.tsx @@ -0,0 +1,59 @@ +import { useCallback, useRef, useState } from "react"; +import { CodeEditor } from "renderer/screens/main/components/WorkspaceView/components/CodeEditor"; +import { detectLanguage } from "shared/detect-language"; +import { ExternalChangeBar } from "../../components/ExternalChangeBar"; + +interface CodeRendererProps { + content: string; + filePath: string; + hasExternalChange: boolean; + onDirtyChange: (dirty: boolean) => void; + onReload: () => Promise; + onSave: (content: string) => Promise; +} + +export function CodeRenderer({ + content, + filePath, + hasExternalChange, + onDirtyChange, + onReload, + onSave, +}: CodeRendererProps) { + const language = detectLanguage(filePath); + const currentContentRef = useRef(content); + const [savedContent, setSavedContent] = useState(content); + + // Track the initial/saved content to detect dirty state + if (content !== savedContent && !onDirtyChange) { + setSavedContent(content); + } + + const handleChange = useCallback( + (value: string) => { + currentContentRef.current = value; + onDirtyChange(value !== savedContent); + }, + [onDirtyChange, savedContent], + ); + + const handleSave = useCallback(async () => { + await onSave(currentContentRef.current); + setSavedContent(currentContentRef.current); + }, [onSave]); + + return ( +
+ {hasExternalChange && } +
+ +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/CodeRenderer/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/CodeRenderer/index.ts new file mode 100644 index 00000000000..f3eecc8cfa8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/CodeRenderer/index.ts @@ -0,0 +1 @@ +export { CodeRenderer } from "./CodeRenderer"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/ImageRenderer/ImageRenderer.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/ImageRenderer/ImageRenderer.tsx new file mode 100644 index 00000000000..75e77780927 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/ImageRenderer/ImageRenderer.tsx @@ -0,0 +1,30 @@ +import { useMemo } from "react"; +import { getImageMimeType } from "shared/file-types"; + +interface ImageRendererProps { + content: Uint8Array; + filePath: string; +} + +export function ImageRenderer({ content, filePath }: ImageRendererProps) { + const dataUrl = useMemo(() => { + const mimeType = getImageMimeType(filePath) ?? "image/png"; + const base64 = btoa( + Array.from(content) + .map((b) => String.fromCharCode(b)) + .join(""), + ); + return `data:${mimeType};base64,${base64}`; + }, [content, filePath]); + + return ( +
+ {filePath.split("/").pop() +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/ImageRenderer/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/ImageRenderer/index.ts new file mode 100644 index 00000000000..d61a1b37f40 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/ImageRenderer/index.ts @@ -0,0 +1 @@ +export { ImageRenderer } from "./ImageRenderer"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/MarkdownRenderer/MarkdownRenderer.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/MarkdownRenderer/MarkdownRenderer.tsx new file mode 100644 index 00000000000..77a25aa3279 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/MarkdownRenderer/MarkdownRenderer.tsx @@ -0,0 +1,97 @@ +import { useCallback, useRef, useState } from "react"; +import { TipTapMarkdownRenderer } from "renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer"; +import { CodeEditor } from "renderer/screens/main/components/WorkspaceView/components/CodeEditor"; +import { ExternalChangeBar } from "../../components/ExternalChangeBar"; + +export type MarkdownViewMode = "rendered" | "raw"; + +interface MarkdownRendererProps { + content: string; + hasExternalChange: boolean; + onDirtyChange: (dirty: boolean) => void; + onReload: () => Promise; + onSave: (content: string) => Promise; +} + +export function MarkdownRenderer({ + content, + hasExternalChange, + onDirtyChange, + onReload, + onSave, +}: MarkdownRendererProps) { + const [viewMode, _setViewMode] = useState("rendered"); + const currentContentRef = useRef(content); + const [savedContent, setSavedContent] = useState(content); + + const handleChange = useCallback( + (value: string) => { + currentContentRef.current = value; + onDirtyChange(value !== savedContent); + }, + [onDirtyChange, savedContent], + ); + + const handleSave = useCallback(async () => { + await onSave(currentContentRef.current); + setSavedContent(currentContentRef.current); + }, [onSave]); + + return ( +
+ {hasExternalChange && } +
+ {viewMode === "rendered" ? ( +
+ +
+ ) : ( + + )} +
+
+ ); +} + +// Exported for use in renderHeaderExtras +export type { MarkdownViewMode as ViewMode }; + +interface ViewModeToggleProps { + viewMode: MarkdownViewMode; + onViewModeChange: (mode: MarkdownViewMode) => void; +} + +export function MarkdownViewModeToggle({ + viewMode, + onViewModeChange, +}: ViewModeToggleProps) { + return ( +
+ + +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/MarkdownRenderer/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/MarkdownRenderer/index.ts new file mode 100644 index 00000000000..eb4c7b8f970 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/MarkdownRenderer/index.ts @@ -0,0 +1,5 @@ +export { + MarkdownRenderer, + type MarkdownViewMode, + MarkdownViewModeToggle, +} from "./MarkdownRenderer"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilesPane/components/WorkspaceFilePreview/WorkspaceFilePreview.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilesPane/components/WorkspaceFilePreview/WorkspaceFilePreview.tsx deleted file mode 100644 index b34d7787ce8..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilesPane/components/WorkspaceFilePreview/WorkspaceFilePreview.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { WorkspaceFilePreviewContent } from "./components/WorkspaceFilePreviewContent"; - -interface WorkspaceFilePreviewProps { - selectedFilePath?: string; - workspaceId: string; -} - -export function WorkspaceFilePreview({ - selectedFilePath, - workspaceId, -}: WorkspaceFilePreviewProps) { - if (!selectedFilePath) { - return ( -
- Select a file to preview it -
- ); - } - - return ( - - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilesPane/components/WorkspaceFilePreview/components/WorkspaceFilePreviewContent/WorkspaceFilePreviewContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilesPane/components/WorkspaceFilePreview/components/WorkspaceFilePreviewContent/WorkspaceFilePreviewContent.tsx deleted file mode 100644 index e293635b91a..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilesPane/components/WorkspaceFilePreview/components/WorkspaceFilePreviewContent/WorkspaceFilePreviewContent.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { useFileDocument } from "@superset/workspace-client"; - -interface WorkspaceFilePreviewContentProps { - selectedFilePath: string; - workspaceId: string; -} - -export function WorkspaceFilePreviewContent({ - selectedFilePath, - workspaceId, -}: WorkspaceFilePreviewContentProps) { - const document = useFileDocument({ - workspaceId, - absolutePath: selectedFilePath, - mode: "auto", - }); - - if (document.state.kind === "loading") { - return ( -
- Loading file... -
- ); - } - - if (document.state.kind === "not-found") { - return ( -
- File not found -
- ); - } - - if (document.state.kind === "binary") { - return ( -
- Binary files are not previewed yet -
- ); - } - - if (document.state.kind === "too-large") { - return ( -
- File is too large to preview -
- ); - } - - if (document.state.kind === "bytes") { - return ( -
- Byte previews are not implemented yet -
- ); - } - - return ( -
-
-
-
-

- {document.absolutePath} -

-

- Revision {document.state.revision} -

-
- -
- {document.hasExternalChange ? ( -

- File changed on disk. Reload to sync with the workspace. -

- ) : null} -
-
-				{document.state.content}
-			
-
- ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilesPane/components/WorkspaceFilePreview/components/WorkspaceFilePreviewContent/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilesPane/components/WorkspaceFilePreview/components/WorkspaceFilePreviewContent/index.ts deleted file mode 100644 index 5a8ef005640..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilesPane/components/WorkspaceFilePreview/components/WorkspaceFilePreviewContent/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { WorkspaceFilePreviewContent } from "./WorkspaceFilePreviewContent"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilesPane/components/WorkspaceFilePreview/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilesPane/components/WorkspaceFilePreview/index.ts deleted file mode 100644 index 41335072140..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilesPane/components/WorkspaceFilePreview/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { WorkspaceFilePreview } from "./WorkspaceFilePreview"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilesPane/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilesPane/index.ts deleted file mode 100644 index 12a28cf46a2..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilesPane/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { FilesPane } from "./FilesPane"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index d8331c359b8..d210db7fa6b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -1,6 +1,8 @@ import type { PaneRegistry, RendererContext } from "@superset/panes"; -import { FileCode2, Globe, MessageSquare, TerminalSquare } from "lucide-react"; +import { alert } from "@superset/ui/atoms/Alert"; +import { Circle, Globe, MessageSquare, TerminalSquare } from "lucide-react"; import { useMemo } from "react"; +import { FileIcon } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils"; import type { BrowserPaneData, ChatPaneData, @@ -9,10 +11,10 @@ import type { PaneViewerData, } from "../../types"; import { ChatPane } from "./components/ChatPane"; -import { WorkspaceFilePreview } from "./components/FilesPane/components/WorkspaceFilePreview/WorkspaceFilePreview"; +import { FilePane } from "./components/FilePane"; import { TerminalPane } from "./components/TerminalPane"; -function getFileTitle(filePath: string): string { +function getFileName(filePath: string): string { return filePath.split("/").pop() ?? filePath; } @@ -22,20 +24,60 @@ export function usePaneRegistry( return useMemo>( () => ({ file: { - getIcon: () => , - getTitle: (ctx: RendererContext) => { + getIcon: (ctx: RendererContext) => { const data = ctx.pane.data as FilePaneData; - return getFileTitle(data.filePath); + const name = getFileName(data.filePath); + return ; }, - renderPane: (ctx: RendererContext) => { + getTitle: (ctx: RendererContext) => { const data = ctx.pane.data as FilePaneData; + const name = getFileName(data.filePath); return ( - +
+ + {name} + + {data.hasChanges && ( + + )} +
); }, + renderPane: (ctx: RendererContext) => ( + + ), + onHeaderClick: (ctx: RendererContext) => + ctx.actions.pin(), + onBeforeClose: (pane) => { + const data = pane.data as FilePaneData; + if (!data.hasChanges) return true; + const name = data.filePath.split("/").pop(); + return new Promise((resolve) => { + alert({ + title: `Do you want to save the changes you made to ${name}?`, + description: "Your changes will be lost if you don't save them.", + actions: [ + { + label: "Save", + onClick: () => { + // TODO: wire up save via editor ref + resolve(true); + }, + }, + { + label: "Don't Save", + variant: "secondary", + onClick: () => resolve(true), + }, + { + label: "Cancel", + variant: "ghost", + onClick: () => resolve(false), + }, + ], + }); + }); + }, }, terminal: { getIcon: () => , diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index fedbe7a2b74..901487c56d0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -1,4 +1,10 @@ import { type PaneActionConfig, Workspace } from "@superset/panes"; +import { alert } from "@superset/ui/atoms/Alert"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@superset/ui/resizable"; import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; @@ -14,7 +20,9 @@ import { } from "renderer/screens/main/components/CommandPalette"; import { PresetsBar } from "renderer/screens/main/components/WorkspaceView/ContentView/components/PresetsBar"; import { useAppHotkey } from "renderer/stores/hotkeys"; +import { useStore } from "zustand"; import { AddTabMenu } from "./components/AddTabMenu"; +import { RightSidebar } from "./components/RightSidebar"; import { WorkspaceEmptyState } from "./components/WorkspaceEmptyState"; import { WorkspaceNotFoundState } from "./components/WorkspaceNotFoundState"; import { usePaneRegistry } from "./hooks/usePaneRegistry"; @@ -73,7 +81,10 @@ function WorkspaceContent({ workspaceName: string; }) { const navigate = useNavigate(); - const { store } = useV2WorkspacePaneLayout({ projectId, workspaceId }); + const { localWorkspaceState, store } = useV2WorkspacePaneLayout({ + projectId, + workspaceId, + }); const paneRegistry = usePaneRegistry(workspaceId); const utils = electronTrpc.useUtils(); @@ -98,9 +109,26 @@ function WorkspaceContent({ }, ); + const selectedFilePath = useStore(store, (s) => { + const tab = s.tabs.find((t) => t.id === s.activeTabId); + if (!tab?.activePaneId) return undefined; + const pane = tab.panes[tab.activePaneId]; + if (pane?.kind === "file") return (pane.data as FilePaneData).filePath; + return undefined; + }); + const openFilePane = useCallback( (filePath: string) => { - store.getState().openPane({ + const state = store.getState(); + const active = state.getActivePane(); + if ( + active?.pane.kind === "file" && + (active.pane.data as FilePaneData).filePath === filePath + ) { + state.setPanePinned({ paneId: active.pane.id, pinned: true }); + return; + } + state.openPane({ pane: { kind: "file", data: { @@ -216,6 +244,16 @@ function WorkspaceContent({ [workspaceId, workspaceName], ); + const collections = useCollections(); + const sidebarOpen = localWorkspaceState?.rightSidebarOpen ?? false; + const toggleSidebar = useCallback(() => { + if (!collections.v2WorkspaceLocalState.get(workspaceId)) return; + collections.v2WorkspaceLocalState.update(workspaceId, (draft) => { + draft.rightSidebarOpen = !draft.rightSidebarOpen; + }); + }, [collections, workspaceId]); + + useAppHotkey("TOGGLE_SIDEBAR", toggleSidebar, undefined, [toggleSidebar]); useAppHotkey("NEW_GROUP", addTerminalTab, undefined, [addTerminalTab]); useAppHotkey("NEW_CHAT", addChatTab, undefined, [addChatTab]); useAppHotkey("NEW_BROWSER", addBrowserTab, undefined, [addBrowserTab]); @@ -223,36 +261,93 @@ function WorkspaceContent({ return ( <> -
- {!isLoadingPresetsBar && showPresetsBar ? : null} - - registry={paneRegistry} - paneActions={defaultPaneActions} - renderAddTabMenu={() => ( - - setShowPresetsBar.mutate({ enabled }) - } - /> - )} - renderEmptyState={() => ( - + +
+ {!isLoadingPresetsBar && showPresetsBar ? : null} + + registry={paneRegistry} + paneActions={defaultPaneActions} + renderAddTabMenu={() => ( + + setShowPresetsBar.mutate({ enabled }) + } + /> + )} + renderEmptyState={() => ( + + )} + onBeforeCloseTab={(tab) => { + const dirtyFiles = Object.values(tab.panes) + .filter( + (p) => + p.kind === "file" && (p.data as FilePaneData).hasChanges, + ) + .map((p) => + (p.data as FilePaneData).filePath.split("/").pop(), + ); + if (dirtyFiles.length === 0) return true; + const title = + dirtyFiles.length === 1 + ? `Do you want to save the changes you made to ${dirtyFiles[0]}?` + : `Do you want to save changes to ${dirtyFiles.length} files?`; + return new Promise((resolve) => { + alert({ + title, + description: + "Your changes will be lost if you don't save them.", + actions: [ + { + label: "Save All", + onClick: () => { + // TODO: wire up save via editor refs + resolve(true); + }, + }, + { + label: "Don't Save", + variant: "secondary", + onClick: () => resolve(true), + }, + { + label: "Cancel", + variant: "ghost", + onClick: () => resolve(false), + }, + ], + }); + }); + }} + store={store} /> - )} - store={store} - /> -
+
+ + {sidebarOpen && ( + <> + + + + + + )} + { - alert.destructive({ + alert({ title: "Revoke API Key", description: `Are you sure you want to revoke "${name ?? "Unnamed Key"}"? This action cannot be undone.`, - confirmText: "Revoke", - onConfirm: async () => { - await authClient.apiKey.delete({ keyId: id }); - toast.success("API key revoked"); - }, + actions: [ + { label: "Cancel", variant: "outline", onClick: () => {} }, + { + label: "Revoke", + variant: "destructive", + onClick: async () => { + await authClient.apiKey.delete({ keyId: id }); + toast.success("API key revoked"); + }, + }, + ], }); }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/members/components/MembersSettings/components/InviteMemberButton/InviteMemberButton.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/members/components/MembersSettings/components/InviteMemberButton/InviteMemberButton.tsx index a17e45e44d3..bb3f8907410 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/members/components/MembersSettings/components/InviteMemberButton/InviteMemberButton.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/members/components/MembersSettings/components/InviteMemberButton/InviteMemberButton.tsx @@ -36,9 +36,10 @@ export function InviteMemberButton({ title: "This will affect your billing", description: "Adding members will increase your subscription cost, prorated to your billing cycle.", - confirmText: "Continue", - cancelText: "Cancel", - onConfirm: () => setOpen(true), + actions: [ + { label: "Cancel", variant: "outline", onClick: () => {} }, + { label: "Continue", onClick: () => setOpen(true) }, + ], }); }); }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/members/components/MembersSettings/components/MemberActions/MemberActions.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/members/components/MembersSettings/components/MemberActions/MemberActions.tsx index ea8c1bc8770..70408da1054 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/members/components/MembersSettings/components/MemberActions/MemberActions.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/members/components/MembersSettings/components/MemberActions/MemberActions.tsx @@ -90,16 +90,19 @@ export function MemberActions({ ? " Your subscription will be adjusted accordingly." : ""; - alert.destructive({ + alert({ title: isCurrentUser ? "Leave organization?" : "Remove team member?", description: isCurrentUser ? `Are you sure you want to leave this organization? You will lose access immediately.${billingNote}` : `Are you sure you want to remove ${member.name} (${member.email}) from the organization? They will lose access immediately.${billingNote}`, - confirmText: isCurrentUser ? "Leave Organization" : "Remove Member", - cancelText: "Cancel", - onConfirm: () => { - handleRemove(); - }, + actions: [ + { label: "Cancel", variant: "outline", onClick: () => {} }, + { + label: isCurrentUser ? "Leave Organization" : "Remove Member", + variant: "destructive", + onClick: () => handleRemove(), + }, + ], }); }; @@ -126,14 +129,17 @@ export function MemberActions({ isCurrentUser && getRoleLevel(newRole) < getRoleLevel(member.role); if (isSelfDemotion) { - alert.destructive({ + alert({ title: "Demote yourself?", description: `You're about to change your role from ${ORGANIZATION_ROLES[member.role].name} to ${ORGANIZATION_ROLES[newRole].name}. Another owner will need to restore your permissions. Are you sure?`, - confirmText: "Yes, demote me", - cancelText: "Cancel", - onConfirm: async () => { - await handleChangeRole(newRole); - }, + actions: [ + { label: "Cancel", variant: "outline", onClick: () => {} }, + { + label: "Yes, demote me", + variant: "destructive", + onClick: () => handleChangeRole(newRole), + }, + ], }); } else { handleChangeRole(newRole); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/cloud/secrets/components/SecretsSettings/components/AddSecretSheet/AddSecretSheet.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/cloud/secrets/components/SecretsSettings/components/AddSecretSheet/AddSecretSheet.tsx index 760e4e30c2f..39a7cbc8119 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/cloud/secrets/components/SecretsSettings/components/AddSecretSheet/AddSecretSheet.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/cloud/secrets/components/SecretsSettings/components/AddSecretSheet/AddSecretSheet.tsx @@ -73,12 +73,18 @@ export function AddSecretSheet({ const handleOpenChange = (nextOpen: boolean) => { if (!nextOpen && hasContent) { - alert.destructive({ + alert({ title: "Discard unsaved changes?", description: "You have unsaved environment variables. Are you sure you want to close?", - confirmText: "Discard", - onConfirm: () => onOpenChange(false), + actions: [ + { label: "Cancel", variant: "outline", onClick: () => {} }, + { + label: "Discard", + variant: "destructive", + onClick: () => onOpenChange(false), + }, + ], }); return; } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/components/SessionSelectorItem/SessionSelectorItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/components/SessionSelectorItem/SessionSelectorItem.tsx index 87150c06a3f..a97f0b09753 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/components/SessionSelectorItem/SessionSelectorItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/components/SessionSelectorItem/SessionSelectorItem.tsx @@ -36,17 +36,23 @@ export function SessionSelectorItem({ className="shrink-0 rounded p-0.5 opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100" onClick={(event) => { event.stopPropagation(); - alert.destructive({ + alert({ title: "Delete Chat Session", description: "Are you sure you want to delete this session?", - confirmText: "Delete", - onConfirm: () => { - toast.promise(onDeleteSession(sessionId), { - loading: "Deleting session...", - success: "Session deleted", - error: "Failed to delete session", - }); - }, + actions: [ + { label: "Cancel", variant: "outline", onClick: () => {} }, + { + label: "Delete", + variant: "destructive", + onClick: () => { + toast.promise(onDeleteSession(sessionId), { + loading: "Deleting session...", + success: "Session deleted", + error: "Failed to delete session", + }); + }, + }, + ], }); }} > diff --git a/packages/panes/src/core/store/store.test.ts b/packages/panes/src/core/store/store.test.ts index 23b556e3fed..de9d8143be7 100644 --- a/packages/panes/src/core/store/store.test.ts +++ b/packages/panes/src/core/store/store.test.ts @@ -138,7 +138,6 @@ describe("pane operations", () => { store.getState().addTab({ id: "t1", panes: [tp("p1")] }); store.getState().setPanePinned({ - tabId: "t1", paneId: "p1", pinned: true, }); diff --git a/packages/panes/src/core/store/store.ts b/packages/panes/src/core/store/store.ts index 2086e054bdf..8bfeff72a86 100644 --- a/packages/panes/src/core/store/store.ts +++ b/packages/panes/src/core/store/store.ts @@ -105,11 +105,7 @@ export interface WorkspaceStore extends WorkspaceState { paneId: string; titleOverride?: string; }) => void; - setPanePinned: (args: { - tabId: string; - paneId: string; - pinned: boolean; - }) => void; + setPanePinned: (args: { paneId: string; pinned: boolean }) => void; replacePane: (args: { tabId: string; paneId: string; @@ -334,26 +330,25 @@ export function createWorkspaceStore( setPanePinned: (args) => { set((s) => { - const tab = s.tabs.find((t) => t.id === args.tabId); - const pane = tab?.panes[args.paneId]; - if (!tab || !pane) return s; - - return { - tabs: s.tabs.map((t) => - t.id === args.tabId - ? { - ...t, - panes: { - ...t.panes, - [args.paneId]: { - ...pane, - pinned: args.pinned, - }, - }, - } - : t, - ), - }; + for (const tab of s.tabs) { + const pane = tab.panes[args.paneId]; + if (pane) { + return { + tabs: s.tabs.map((t) => + t.id === tab.id + ? { + ...t, + panes: { + ...t.panes, + [args.paneId]: { ...pane, pinned: args.pinned }, + }, + } + : t, + ), + }; + } + } + return s; }); }, diff --git a/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/Pane.tsx b/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/Pane.tsx index 6114aab8862..d9dde1bc58b 100644 --- a/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/Pane.tsx +++ b/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/Pane.tsx @@ -81,8 +81,13 @@ export function Pane({ isActive, store, actions: { - close: () => - store.getState().closePane({ tabId: tab.id, paneId: pane.id }), + close: async () => { + if (definition?.onBeforeClose) { + const allowed = await definition.onBeforeClose(pane); + if (!allowed) return; + } + store.getState().closePane({ tabId: tab.id, paneId: pane.id }); + }, focus: () => store.getState().setActivePane({ tabId: tab.id, paneId: pane.id }), setTitle: (title: string) => @@ -93,7 +98,6 @@ export function Pane({ }), pin: () => store.getState().setPanePinned({ - tabId: tab.id, paneId: pane.id, pinned: true, }), @@ -216,6 +220,12 @@ export function Pane({ toolbar={toolbar} actionsContent={} paneId={pane.id} + onClick={ + definition?.onHeaderClick + ? () => definition.onHeaderClick?.(context) + : context.actions.pin + } + onMiddleClick={context.actions.close} /> {definition ? ( diff --git a/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/components/PaneHeader/PaneHeader.tsx b/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/components/PaneHeader/PaneHeader.tsx index 640a2dda3dc..fbe9aadfdf2 100644 --- a/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/components/PaneHeader/PaneHeader.tsx +++ b/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/components/PaneHeader/PaneHeader.tsx @@ -12,6 +12,8 @@ interface PaneHeaderProps { actionsContent: ReactNode; toolbar?: ReactNode; paneId?: string; + onClick?: () => void; + onMiddleClick?: () => void; } export const PANE_DRAG_TYPE = "pane"; @@ -25,6 +27,8 @@ export function PaneHeader({ actionsContent, toolbar, paneId, + onClick, + onMiddleClick, }: PaneHeaderProps) { const [{ isDragging }, connectDrag] = useDrag( () => ({ @@ -48,6 +52,8 @@ export function PaneHeader({ ); return ( + // biome-ignore lint/a11y/useKeyWithClickEvents: pane header click-to-pin doesn't need keyboard equivalent + // biome-ignore lint/a11y/noStaticElementInteractions: click to pin, middle-click to close
{ + if (e.button === 1 && onMiddleClick) { + e.preventDefault(); + onMiddleClick(); + } + }} > {toolbar ?? ( { renderTitle?(context: RendererContext): ReactNode; renderHeaderExtras?(context: RendererContext): ReactNode; renderToolbar?(context: RendererContext): ReactNode; + onHeaderClick?(context: RendererContext): void; + onBeforeClose?(pane: Pane): boolean | Promise; paneActions?: | PaneActionConfig[] | (( diff --git a/packages/ui/src/atoms/Alert/Alert.tsx b/packages/ui/src/atoms/Alert/Alert.tsx index 3cb36d4ba14..4051ba650b4 100644 --- a/packages/ui/src/atoms/Alert/Alert.tsx +++ b/packages/ui/src/atoms/Alert/Alert.tsx @@ -1,6 +1,6 @@ "use client"; -import { Button } from "@superset/ui/button"; +import { Button, type buttonVariants } from "@superset/ui/button"; import { Dialog, DialogContent, @@ -9,100 +9,95 @@ import { DialogHeader, DialogTitle, } from "@superset/ui/dialog"; +import type { VariantProps } from "class-variance-authority"; import { useState } from "react"; +type AlertActionVariant = NonNullable< + VariantProps["variant"] +>; + +interface AlertAction { + label: string; + variant?: AlertActionVariant; + onClick: () => void | Promise; +} + type AlertOptions = { title: string; description: string; - confirmText?: string; - cancelText?: string; - onConfirm: () => void | Promise; - onCancel?: () => void; -}; - -type InternalAlertOptions = AlertOptions & { - variant: "default" | "destructive"; + actions: AlertAction[]; }; -let showAlertFn: ((options: InternalAlertOptions) => void) | null = null; +let showAlertFn: ((options: AlertOptions) => void) | null = null; const Alerter = () => { - const [alertOptions, setAlertOptions] = useState( - null, - ); + const [alertOptions, setAlertOptions] = useState(null); const [isOpen, setIsOpen] = useState(false); - const [isLoading, setIsLoading] = useState(false); + const [loadingIndex, setLoadingIndex] = useState(null); showAlertFn = (options) => { setAlertOptions(options); + setLoadingIndex(null); setIsOpen(true); }; - const handleConfirm = async () => { - if (!alertOptions) return; - - setIsLoading(true); + const handleAction = async (action: AlertAction, index: number) => { + setLoadingIndex(index); try { - await alertOptions.onConfirm(); + await action.onClick(); setIsOpen(false); } catch (error) { - console.error("[alert] Confirmation failed:", error); + console.error("[alert] Action failed:", error); } finally { - setIsLoading(false); + setLoadingIndex(null); } }; - const handleCancel = () => { - if (!alertOptions) return; - alertOptions.onCancel?.(); + const handleClose = () => { setIsOpen(false); }; + if (!alertOptions) return null; + + const actions = [...alertOptions.actions].reverse(); + return ( !open && handleCancel()} + onOpenChange={(open) => !open && handleClose()} > - {alertOptions?.title} - {alertOptions?.description} + {alertOptions.title} + {alertOptions.description} - - + {actions.map((action, i) => ( + + ))} ); }; -const createAlert = (variant: "default" | "destructive") => { - return (options: AlertOptions) => { - if (!showAlertFn) { - console.error( - "[alert] Alerter not mounted. Make sure to render in your app", - ); - return; - } - const internalOptions: InternalAlertOptions = { ...options, variant }; - showAlertFn(internalOptions); - }; +const alert = (options: AlertOptions) => { + if (!showAlertFn) { + console.error( + "[alert] Alerter not mounted. Make sure to render in your app", + ); + return; + } + showAlertFn(options); }; -const alert = Object.assign(createAlert("default"), { - destructive: createAlert("destructive"), -}); - export { Alerter, alert }; +export type { AlertAction, AlertActionVariant, AlertOptions }; diff --git a/packages/ui/src/atoms/Alert/index.ts b/packages/ui/src/atoms/Alert/index.ts index 382c890a97a..7ba60484d14 100644 --- a/packages/ui/src/atoms/Alert/index.ts +++ b/packages/ui/src/atoms/Alert/index.ts @@ -1 +1,2 @@ +export type { AlertAction, AlertActionVariant, AlertOptions } from "./Alert"; export { Alerter, alert } from "./Alert"; diff --git a/packages/workspace-client/src/hooks/useFileTree/useFileTree.ts b/packages/workspace-client/src/hooks/useFileTree/useFileTree.ts index 172ad9605d1..28272b73d29 100644 --- a/packages/workspace-client/src/hooks/useFileTree/useFileTree.ts +++ b/packages/workspace-client/src/hooks/useFileTree/useFileTree.ts @@ -28,6 +28,7 @@ export interface UseFileTreeResult { toggle: (path: string) => Promise; refreshAll: () => Promise; refreshPath: (path: string) => Promise; + reveal: (path: string) => Promise; } interface FileTreeState { @@ -214,10 +215,10 @@ export function useFileTree({ }); try { - const result = await utils.filesystem.listDirectory.fetch({ - workspaceId, - absolutePath, - }); + const result = await utils.filesystem.listDirectory.fetch( + { workspaceId, absolutePath }, + { staleTime: 0 }, + ); updateState((current) => { const nextEntries = new Map(current.entriesByPath); @@ -479,6 +480,27 @@ export function useFileTree({ state.loadingDirectories, ]); + const reveal = useCallback( + async (absolutePath: string): Promise => { + if (!rootPath || !absolutePath.startsWith(rootPath)) return; + + // Collect ancestor directories from rootPath down to the parent of the target + const ancestors: string[] = []; + let current = getParentPath(absolutePath); + while (current.length >= rootPath.length && current !== absolutePath) { + ancestors.unshift(current); + if (current === rootPath) break; + current = getParentPath(current); + } + + // Expand all ancestors and load their contents + for (const dir of ancestors) { + await expand(dir); + } + }, + [expand, rootPath], + ); + return { isLoadingRoot: state.loadingDirectories.has(rootPath), collapseAll, @@ -488,5 +510,6 @@ export function useFileTree({ toggle, refreshAll, refreshPath, + reveal, }; } From 10d9a5ddebdd755beb3eedc1e292ed13c5fbe69d Mon Sep 17 00:00:00 2001 From: Kiet <31864905+Kitenite@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:04:54 -0700 Subject: [PATCH 4/7] fix(desktop): adjust tiptap editor line-height in pane (#3097) * fix(desktop): reduce line-height in markdown renderer pane styles Tighten line-height and spacing in default and tufte markdown styles for a more compact reading experience in panes. * Lint --- .../MarkdownRenderer/styles/default/default.css | 4 ++-- .../components/MarkdownRenderer/styles/tufte/tufte.css | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/renderer/components/MarkdownRenderer/styles/default/default.css b/apps/desktop/src/renderer/components/MarkdownRenderer/styles/default/default.css index b24a3cde29a..8085dcc3dfe 100644 --- a/apps/desktop/src/renderer/components/MarkdownRenderer/styles/default/default.css +++ b/apps/desktop/src/renderer/components/MarkdownRenderer/styles/default/default.css @@ -48,7 +48,7 @@ .default-markdown p { margin-top: 0; margin-bottom: 1rem; - line-height: 1.7; + line-height: 1.5; } .default-markdown ul, @@ -60,7 +60,7 @@ .default-markdown li { margin-bottom: 0.25rem; - line-height: 1.6; + line-height: 1.5; } .default-markdown ul { diff --git a/apps/desktop/src/renderer/components/MarkdownRenderer/styles/tufte/tufte.css b/apps/desktop/src/renderer/components/MarkdownRenderer/styles/tufte/tufte.css index 5d21f9938a7..edff21d4fcd 100644 --- a/apps/desktop/src/renderer/components/MarkdownRenderer/styles/tufte/tufte.css +++ b/apps/desktop/src/renderer/components/MarkdownRenderer/styles/tufte/tufte.css @@ -2,7 +2,7 @@ .tufte-markdown { font-family: Georgia, "Times New Roman", serif; font-size: 1.1rem; - line-height: 1.8; + line-height: 1.6; color: var(--foreground); } @@ -84,7 +84,7 @@ /* Body text */ .tufte-markdown p { margin-top: 0; - margin-bottom: 1.4rem; + margin-bottom: 1.1rem; text-align: justify; -webkit-hyphens: auto; hyphens: auto; @@ -101,7 +101,7 @@ /* Lists */ .tufte-markdown ul, .tufte-markdown ol { - margin: 1.4rem 0; + margin: 1.1rem 0; padding-left: 1.5rem; list-style-position: outside; } @@ -115,7 +115,8 @@ } .tufte-markdown li { - margin-bottom: 0.5rem; + margin-bottom: 0.35rem; + line-height: 1.5; } /* Links */ From 337a9aecaff51c100e2ba297b89d31e8a451d614 Mon Sep 17 00:00:00 2001 From: Kiet <31864905+Kitenite@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:09:01 -0700 Subject: [PATCH 5/7] fix(desktop): remove PreToolUse/PostToolUse hooks from Codex integration (#3121) These per-tool hooks add overhead without providing value for Codex. Only SessionStart, UserPromptSubmit, and Stop are needed. --- apps/desktop/docs/EXTERNAL_FILES.md | 2 +- .../agent-wrappers-claude-codex-opencode.ts | 21 +---- .../lib/agent-setup/agent-wrappers.test.ts | 85 ++++--------------- 3 files changed, 19 insertions(+), 89 deletions(-) diff --git a/apps/desktop/docs/EXTERNAL_FILES.md b/apps/desktop/docs/EXTERNAL_FILES.md index abcc47f5bb8..5cc01ad97cb 100644 --- a/apps/desktop/docs/EXTERNAL_FILES.md +++ b/apps/desktop/docs/EXTERNAL_FILES.md @@ -42,7 +42,7 @@ its hook entries into these files while preserving user-defined entries: | File | Purpose | |------|---------| | `~/.claude/settings.json` | Claude Code hook registration merge | -| `~/.codex/hooks.json` | Codex hook registration merge (`SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`) | +| `~/.codex/hooks.json` | Codex hook registration merge (`SessionStart`, `UserPromptSubmit`, `Stop`) | | `~/.factory/settings.json` | Factory Droid hook registration (`UserPromptSubmit`, `Notification`, `PostToolUse`, `Stop`) | For Codex specifically, Superset now relies on native `~/.codex/hooks.json` diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-claude-codex-opencode.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-claude-codex-opencode.ts index e3378f804f9..db889900cd5 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-claude-codex-opencode.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-claude-codex-opencode.ts @@ -385,12 +385,7 @@ export function getCodexGlobalHooksJsonContent( } const managedEvents: Array<{ - eventName: - | "SessionStart" - | "UserPromptSubmit" - | "PreToolUse" - | "PostToolUse" - | "Stop"; + eventName: "SessionStart" | "UserPromptSubmit" | "Stop"; definition: ClaudeHookDefinition; }> = [ { @@ -405,20 +400,6 @@ export function getCodexGlobalHooksJsonContent( hooks: [{ type: "command", command: notifyScriptPath }], }, }, - { - eventName: "PreToolUse", - definition: { - matcher: "*", - hooks: [{ type: "command", command: notifyScriptPath }], - }, - }, - { - eventName: "PostToolUse", - definition: { - matcher: "*", - hooks: [{ type: "command", command: notifyScriptPath }], - }, - }, { eventName: "Stop", definition: { diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts index 9c968c4c7b6..9e96a3e4912 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts @@ -888,7 +888,7 @@ describe("agent-wrappers codex hooks.json", () => { rmSync(TEST_ROOT, { recursive: true, force: true }); }); - it("creates Codex hooks.json with prompt and tool lifecycle hooks when no file exists", () => { + it("creates Codex hooks.json with prompt and lifecycle hooks when no file exists", () => { const notifyPath = "/tmp/.superset/hooks/notify.sh"; const content = getCodexGlobalHooksJsonContent(notifyPath); expect(content).not.toBeNull(); @@ -907,8 +907,6 @@ describe("agent-wrappers codex hooks.json", () => { for (const eventName of [ "SessionStart", "UserPromptSubmit", - "PreToolUse", - "PostToolUse", "Stop", ] as const) { const hooks = parsed.hooks[eventName]; @@ -920,12 +918,8 @@ describe("agent-wrappers codex hooks.json", () => { ).toBe(true); } - expect(parsed.hooks.PreToolUse?.every((def) => def.matcher === "*")).toBe( - true, - ); - expect(parsed.hooks.PostToolUse?.every((def) => def.matcher === "*")).toBe( - true, - ); + expect(parsed.hooks.PreToolUse).toBeUndefined(); + expect(parsed.hooks.PostToolUse).toBeUndefined(); }); it("preserves user hooks when merging", () => { @@ -987,7 +981,7 @@ describe("agent-wrappers codex hooks.json", () => { const parsed = JSON.parse(content); - // Preserves user hook + // Preserves user hooks (including PreToolUse/PostToolUse which we don't manage) expect( parsed.hooks.Stop.some((def: { hooks: Array<{ command: string }> }) => def.hooks.some( @@ -1024,32 +1018,19 @@ describe("agent-wrappers codex hooks.json", () => { ), ).toBe(true); - // Adds managed hook - expect( - parsed.hooks.Stop.some((def: { hooks: Array<{ command: string }> }) => - def.hooks.some( - (hook: { command: string }) => hook.command === notifyPath, + // Adds managed hooks for SessionStart, UserPromptSubmit, Stop + for (const eventName of ["SessionStart", "UserPromptSubmit", "Stop"]) { + expect( + parsed.hooks[eventName].some( + (def: { hooks: Array<{ command: string }> }) => + def.hooks.some( + (hook: { command: string }) => hook.command === notifyPath, + ), ), - ), - ).toBe(true); + ).toBe(true); + } - // Also creates prompt + start hooks - expect( - parsed.hooks.SessionStart.some( - (def: { hooks: Array<{ command: string }> }) => - def.hooks.some( - (hook: { command: string }) => hook.command === notifyPath, - ), - ), - ).toBe(true); - expect( - parsed.hooks.UserPromptSubmit.some( - (def: { hooks: Array<{ command: string }> }) => - def.hooks.some( - (hook: { command: string }) => hook.command === notifyPath, - ), - ), - ).toBe(true); + // Does NOT inject managed hooks for PreToolUse/PostToolUse expect( parsed.hooks.PreToolUse.some( (def: { hooks: Array<{ command: string }> }) => @@ -1057,7 +1038,7 @@ describe("agent-wrappers codex hooks.json", () => { (hook: { command: string }) => hook.command === notifyPath, ), ), - ).toBe(true); + ).toBe(false); expect( parsed.hooks.PostToolUse.some( (def: { hooks: Array<{ command: string }> }) => @@ -1065,37 +1046,7 @@ describe("agent-wrappers codex hooks.json", () => { (hook: { command: string }) => hook.command === notifyPath, ), ), - ).toBe(true); - }); - - it("adds UserPromptSubmit, PreToolUse, and PostToolUse to the Codex hooks.json merge", () => { - const notifyPath = "/tmp/.superset/hooks/notify.sh"; - const content = getCodexGlobalHooksJsonContent(notifyPath); - expect(content).not.toBeNull(); - if (content === null) throw new Error("Expected content"); - - const parsed = JSON.parse(content) as { - hooks: Record< - string, - Array<{ - matcher?: string; - hooks: Array<{ type: string; command: string }>; - }> - >; - }; - - for (const eventName of [ - "UserPromptSubmit", - "PreToolUse", - "PostToolUse", - ] as const) { - expect(parsed.hooks[eventName]).toBeDefined(); - expect( - parsed.hooks[eventName]?.some((def) => - def.hooks.some((hook) => hook.command === notifyPath), - ), - ).toBe(true); - } + ).toBe(false); }); it("replaces stale Codex hook commands from old superset paths", () => { @@ -1150,8 +1101,6 @@ describe("agent-wrappers codex hooks.json", () => { for (const eventName of [ "SessionStart", "UserPromptSubmit", - "PreToolUse", - "PostToolUse", "Stop", ] as const) { const hooks = parsed.hooks[eventName]; From 016d31597ddf42232c1cf91d81a8ce3257d5e5fc Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Fri, 3 Apr 2026 09:22:33 +0900 Subject: [PATCH 6/7] feat: add Fetch Remote button to Git tab Diffs header Add a cloud download button (IoCloudDownloadOutline) to the left of the Refresh Changes button in the right sidebar's Git tab. Clicking it runs git fetch --prune via a new fetchRemote tRPC mutation. --- .../trpc/routers/changes/git-operations.ts | 9 +++++ .../ChangesHeader/ChangesHeader.tsx | 39 +++++++++++++++++++ bun.lock | 34 ++++++++++------ 3 files changed, 70 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts b/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts index cfda56ddf94..073f790b9bb 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts @@ -343,6 +343,15 @@ export const createGitOperationsRouter = () => { }, ), + fetchRemote: publicProcedure + .input(z.object({ worktreePath: z.string() })) + .mutation(async ({ input }): Promise<{ success: boolean }> => { + assertRegisteredWorktree(input.worktreePath); + const git = await getGitWithShellPath(input.worktreePath); + await git.fetch(["--prune"]); + return { success: true }; + }), + generateCommitMessage: publicProcedure .input(z.object({ worktreePath: z.string() })) .mutation(async ({ input }): Promise<{ message: string | null }> => { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx index 65325e9965d..9fe6eeac3fc 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx @@ -28,6 +28,7 @@ import { useState, } from "react"; import { GoGitBranch, GoGlobe } from "react-icons/go"; +import { IoCloudDownloadOutline } from "react-icons/io5"; import { LuArrowLeft, LuChevronDown, LuPlus, LuTag } from "react-icons/lu"; import { VscCheck, @@ -1132,6 +1133,43 @@ function StashDropdown({ ); } +function FetchRemoteButton({ worktreePath }: { worktreePath: string }) { + const [isFetching, setIsFetching] = useState(false); + const fetchRemote = electronTrpc.changes.fetchRemote.useMutation(); + + const handleClick = async () => { + setIsFetching(true); + try { + await fetchRemote.mutateAsync({ worktreePath }); + } catch { + // ignore fetch errors (e.g., offline) + } finally { + setIsFetching(false); + } + }; + + return ( + + + + + + Fetch remote + + + ); +} + function RefreshButton({ onRefresh }: { onRefresh: () => void }) { const [isSpinning, setIsSpinning] = useState(false); const timeoutRef = useRef(null); @@ -1212,6 +1250,7 @@ export function ChangesHeader({ onViewModeChange={onViewModeChange} /> )} + Date: Fri, 3 Apr 2026 10:00:06 +0900 Subject: [PATCH 7/7] fix: clear status cache and refresh UI after fetch remote - Clear status cache after git fetch --prune so ahead/behind reflects immediately - Call onRefresh() after successful fetch to sync branch/PR display - Show toast error on fetch failure instead of silently ignoring --- .../src/lib/trpc/routers/changes/git-operations.ts | 1 + .../components/ChangesHeader/ChangesHeader.tsx | 13 ++++++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts b/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts index 073f790b9bb..4ce8ad7c9e2 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts @@ -349,6 +349,7 @@ export const createGitOperationsRouter = () => { assertRegisteredWorktree(input.worktreePath); const git = await getGitWithShellPath(input.worktreePath); await git.fetch(["--prune"]); + clearStatusCacheForWorktree(input.worktreePath); return { success: true }; }), diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx index 9fe6eeac3fc..94a497dbd07 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx @@ -1133,7 +1133,13 @@ function StashDropdown({ ); } -function FetchRemoteButton({ worktreePath }: { worktreePath: string }) { +function FetchRemoteButton({ + worktreePath, + onRefresh, +}: { + worktreePath: string; + onRefresh: () => void; +}) { const [isFetching, setIsFetching] = useState(false); const fetchRemote = electronTrpc.changes.fetchRemote.useMutation(); @@ -1141,8 +1147,9 @@ function FetchRemoteButton({ worktreePath }: { worktreePath: string }) { setIsFetching(true); try { await fetchRemote.mutateAsync({ worktreePath }); + onRefresh(); } catch { - // ignore fetch errors (e.g., offline) + toast.error("Failed to fetch remote"); } finally { setIsFetching(false); } @@ -1250,7 +1257,7 @@ export function ChangesHeader({ onViewModeChange={onViewModeChange} /> )} - +