diff --git a/apps/desktop/src/lib/trpc/routers/changes/branches.ts b/apps/desktop/src/lib/trpc/routers/changes/branches.ts index bc63ab91aa3..b2351e98bdd 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/branches.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/branches.ts @@ -4,6 +4,11 @@ import { localDb } from "main/lib/local-db"; import simpleGit from "simple-git"; import { z } from "zod"; import { publicProcedure, router } from "../.."; +import { + assertRegisteredWorktree, + getRegisteredWorktree, + gitSwitchBranch, +} from "./security"; export const createBranchesRouter = () => { return router({ @@ -18,6 +23,8 @@ export const createBranchesRouter = () => { defaultBranch: string; checkedOutBranches: Record; }> => { + assertRegisteredWorktree(input.worktreePath); + const git = simpleGit(input.worktreePath); const branchSummary = await git.branch(["-a"]); @@ -59,18 +66,11 @@ export const createBranchesRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - const git = simpleGit(input.worktreePath); - - const worktree = localDb - .select() - .from(worktrees) - .where(eq(worktrees.path, input.worktreePath)) - .get(); - if (!worktree) { - throw new Error(`No worktree found at path "${input.worktreePath}"`); - } + // Get worktree record for updating branch info + const worktree = getRegisteredWorktree(input.worktreePath); - await git.checkout(input.branch); + // Use gitSwitchBranch which uses `git switch` (correct branch syntax) + await gitSwitchBranch(input.worktreePath, input.branch); // Update the branch in the worktree record const gitStatus = worktree.gitStatus diff --git a/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts b/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts index 8af04dd68e4..b0edb575149 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts @@ -1,11 +1,43 @@ -import { readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; import type { FileContents } from "shared/changes-types"; import simpleGit from "simple-git"; import { z } from "zod"; import { publicProcedure, router } from "../.."; +import { + assertRegisteredWorktree, + PathValidationError, + secureFs, +} from "./security"; import { detectLanguage } from "./utils/parse-status"; +/** Maximum file size for reading (2 MiB) */ +const MAX_FILE_SIZE = 2 * 1024 * 1024; + +/** Bytes to scan for binary detection */ +const BINARY_CHECK_SIZE = 8192; + +/** + * Result type for readWorkingFile procedure + */ +type ReadWorkingFileResult = + | { ok: true; content: string; truncated: boolean; byteLength: number } + | { + ok: false; + reason: "not-found" | "too-large" | "binary" | "outside-worktree"; + }; + +/** + * Detects if a buffer contains binary content by checking for NUL bytes + */ +function isBinaryContent(buffer: Buffer): boolean { + const checkLength = Math.min(buffer.length, BINARY_CHECK_SIZE); + for (let i = 0; i < checkLength; i++) { + if (buffer[i] === 0) { + return true; + } + } + return false; +} + export const createFileContentsRouter = () => { return router({ getFileContents: publicProcedure @@ -20,6 +52,8 @@ export const createFileContentsRouter = () => { }), ) .query(async ({ input }): Promise => { + assertRegisteredWorktree(input.worktreePath); + const git = simpleGit(input.worktreePath); const defaultBranch = input.defaultBranch || "main"; const originalPath = input.oldPath || input.filePath; @@ -50,10 +84,59 @@ export const createFileContentsRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - const fullPath = join(input.worktreePath, input.filePath); - await writeFile(fullPath, input.content, "utf-8"); + // secureFs.writeFile handles all validation including symlink checks + await secureFs.writeFile( + input.worktreePath, + input.filePath, + input.content, + ); return { success: true }; }), + + /** + * Read a working tree file safely with size cap and binary detection. + * Used for File Viewer raw/rendered modes. + */ + readWorkingFile: publicProcedure + .input( + z.object({ + worktreePath: z.string(), + filePath: z.string(), + }), + ) + .query(async ({ input }): Promise => { + try { + // Check file size first (uses stat which follows symlinks) + const stats = await secureFs.stat(input.worktreePath, input.filePath); + if (stats.size > MAX_FILE_SIZE) { + return { ok: false, reason: "too-large" }; + } + + // Read file content as buffer for binary detection + const buffer = await secureFs.readFileBuffer( + input.worktreePath, + input.filePath, + ); + + // Check for binary content + if (isBinaryContent(buffer)) { + return { ok: false, reason: "binary" }; + } + + return { + ok: true, + content: buffer.toString("utf-8"), + truncated: false, + byteLength: buffer.length, + }; + } catch (error) { + if (error instanceof PathValidationError) { + return { ok: false, reason: "outside-worktree" }; + } + // File not found or other read error + return { ok: false, reason: "not-found" }; + } + }), }); }; @@ -91,26 +174,41 @@ async function getFileVersions( } } +/** Helper to safely get git show content with size limit and memory protection */ +async function safeGitShow( + git: ReturnType, + spec: string, +): Promise { + try { + // Preflight: check blob size before loading into memory + // This prevents memory spikes from large files in git history + try { + const sizeOutput = await git.raw(["cat-file", "-s", spec]); + const blobSize = Number.parseInt(sizeOutput.trim(), 10); + if (!Number.isNaN(blobSize) && blobSize > MAX_FILE_SIZE) { + return `[File content truncated - exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit]`; + } + } catch { + // cat-file failed (blob doesn't exist) - let git.show handle the error + } + + const content = await git.show([spec]); + return content; + } catch { + return ""; + } +} + async function getAgainstBaseVersions( git: ReturnType, filePath: string, originalPath: string, defaultBranch: string, ): Promise { - let original = ""; - let modified = ""; - - try { - original = await git.show([`origin/${defaultBranch}:${originalPath}`]); - } catch { - original = ""; - } - - try { - modified = await git.show([`HEAD:${filePath}`]); - } catch { - modified = ""; - } + const [original, modified] = await Promise.all([ + safeGitShow(git, `origin/${defaultBranch}:${originalPath}`), + safeGitShow(git, `HEAD:${filePath}`), + ]); return { original, modified }; } @@ -121,20 +219,10 @@ async function getCommittedVersions( originalPath: string, commitHash: string, ): Promise { - let original = ""; - let modified = ""; - - try { - original = await git.show([`${commitHash}^:${originalPath}`]); - } catch { - original = ""; - } - - try { - modified = await git.show([`${commitHash}:${filePath}`]); - } catch { - modified = ""; - } + const [original, modified] = await Promise.all([ + safeGitShow(git, `${commitHash}^:${originalPath}`), + safeGitShow(git, `${commitHash}:${filePath}`), + ]); return { original, modified }; } @@ -144,20 +232,10 @@ async function getStagedVersions( filePath: string, originalPath: string, ): Promise { - let original = ""; - let modified = ""; - - try { - original = await git.show([`HEAD:${originalPath}`]); - } catch { - original = ""; - } - - try { - modified = await git.show([`:0:${filePath}`]); - } catch { - modified = ""; - } + const [original, modified] = await Promise.all([ + safeGitShow(git, `HEAD:${originalPath}`), + safeGitShow(git, `:0:${filePath}`), + ]); return { original, modified }; } @@ -168,22 +246,23 @@ async function getUnstagedVersions( filePath: string, originalPath: string, ): Promise { - let original = ""; - let modified = ""; - - try { - original = await git.show([`:0:${originalPath}`]); - } catch { - try { - original = await git.show([`HEAD:${originalPath}`]); - } catch { - original = ""; - } + // Try staged version first, fall back to HEAD + let original = await safeGitShow(git, `:0:${originalPath}`); + if (!original) { + original = await safeGitShow(git, `HEAD:${originalPath}`); } + let modified = ""; try { - modified = await readFile(join(worktreePath, filePath), "utf-8"); + // Check file size before reading (uses stat which follows symlinks) + const stats = await secureFs.stat(worktreePath, filePath); + if (stats.size <= MAX_FILE_SIZE) { + modified = await secureFs.readFile(worktreePath, filePath); + } else { + modified = `[File content truncated - exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit]`; + } } catch { + // File doesn't exist or validation failed - that's ok for diff display modified = ""; } 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 6e6f584cfe4..35f58d7c956 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts @@ -1,10 +1,9 @@ -import { writeFile } from "node:fs/promises"; -import { resolve } from "node:path"; import { shell } from "electron"; import simpleGit from "simple-git"; import { z } from "zod"; import { publicProcedure, router } from "../.."; import { isUpstreamMissingError } from "./git-utils"; +import { assertRegisteredWorktree } from "./security"; export { isUpstreamMissingError }; @@ -21,25 +20,8 @@ async function hasUpstreamBranch( export const createGitOperationsRouter = () => { return router({ - saveFile: publicProcedure - .input( - z.object({ - worktreePath: z.string(), - filePath: z.string(), - content: z.string(), - }), - ) - .mutation(async ({ input }): Promise<{ success: boolean }> => { - const resolvedWorktree = resolve(input.worktreePath); - const fullPath = resolve(resolvedWorktree, input.filePath); - - if (!fullPath.startsWith(`${resolvedWorktree}/`)) { - throw new Error("Invalid file path: path traversal detected"); - } - - await writeFile(fullPath, input.content, "utf-8"); - return { success: true }; - }), + // NOTE: saveFile is defined in file-contents.ts with hardened path validation + // Do NOT add saveFile here - it would overwrite the secure version commit: publicProcedure .input( @@ -50,6 +32,9 @@ export const createGitOperationsRouter = () => { ) .mutation( async ({ input }): Promise<{ success: boolean; hash: string }> => { + // SECURITY: Validate worktreePath exists in localDb + assertRegisteredWorktree(input.worktreePath); + const git = simpleGit(input.worktreePath); const result = await git.commit(input.message); return { success: true, hash: result.commit }; @@ -64,6 +49,9 @@ export const createGitOperationsRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { + // SECURITY: Validate worktreePath exists in localDb + assertRegisteredWorktree(input.worktreePath); + const git = simpleGit(input.worktreePath); const hasUpstream = await hasUpstreamBranch(git); @@ -84,6 +72,9 @@ export const createGitOperationsRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { + // SECURITY: Validate worktreePath exists in localDb + assertRegisteredWorktree(input.worktreePath); + const git = simpleGit(input.worktreePath); try { await git.pull(["--rebase"]); @@ -107,6 +98,9 @@ export const createGitOperationsRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { + // SECURITY: Validate worktreePath exists in localDb + assertRegisteredWorktree(input.worktreePath); + const git = simpleGit(input.worktreePath); try { await git.pull(["--rebase"]); @@ -134,6 +128,9 @@ export const createGitOperationsRouter = () => { ) .mutation( async ({ input }): Promise<{ success: boolean; url: string }> => { + // SECURITY: Validate worktreePath exists in localDb + assertRegisteredWorktree(input.worktreePath); + const git = simpleGit(input.worktreePath); const branch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim(); const hasUpstream = await hasUpstreamBranch(git); diff --git a/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts b/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts new file mode 100644 index 00000000000..643c7826e6d --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts @@ -0,0 +1,139 @@ +import simpleGit from "simple-git"; +import { + assertRegisteredWorktree, + assertValidGitPath, +} from "./path-validation"; + +/** + * Git command helpers with semantic naming. + * + * Design principle: Different functions for different git semantics. + * You can't accidentally use file checkout syntax for branch switching. + * + * Each function: + * 1. Validates worktree is registered + * 2. Validates paths/refs as appropriate + * 3. Uses the correct git command syntax + */ + +/** + * Switch to a branch. + * + * Uses `git switch` (unambiguous branch operation, git 2.23+). + * Falls back to `git checkout ` for older git versions. + * + * Note: `git checkout -- ` is WRONG - that's file checkout syntax. + */ +export async function gitSwitchBranch( + worktreePath: string, + branch: string, +): Promise { + assertRegisteredWorktree(worktreePath); + + // Validate: reject anything that looks like a flag + if (branch.startsWith("-")) { + throw new Error("Invalid branch name: cannot start with -"); + } + + // Validate: reject empty branch names + if (!branch.trim()) { + throw new Error("Invalid branch name: cannot be empty"); + } + + const git = simpleGit(worktreePath); + + try { + // Prefer `git switch` - unambiguous branch operation (git 2.23+) + await git.raw(["switch", branch]); + } catch (switchError) { + // Check if it's because `switch` command doesn't exist (old git) + const errorMessage = String(switchError); + if ( + errorMessage.includes("is not a git command") || + errorMessage.includes("unknown switch") + ) { + // Fallback for older git versions + // Note: checkout WITHOUT -- is correct for branches + await git.checkout(branch); + } else { + throw switchError; + } + } +} + +/** + * Checkout (restore) a file path, discarding local changes. + * + * Uses `git checkout -- ` - the `--` is REQUIRED here + * to indicate path mode (not branch mode). + */ +export async function gitCheckoutFile( + worktreePath: string, + filePath: string, +): Promise { + assertRegisteredWorktree(worktreePath); + assertValidGitPath(filePath); + + const git = simpleGit(worktreePath); + // `--` is correct here - we want path semantics + await git.checkout(["--", filePath]); +} + +/** + * Stage a file for commit. + * + * Uses `git add -- ` - the `--` prevents paths starting + * with `-` from being interpreted as flags. + */ +export async function gitStageFile( + worktreePath: string, + filePath: string, +): Promise { + assertRegisteredWorktree(worktreePath); + assertValidGitPath(filePath); + + const git = simpleGit(worktreePath); + await git.add(["--", filePath]); +} + +/** + * Stage all changes for commit. + * + * Uses `git add -A` to stage all changes (new, modified, deleted). + */ +export async function gitStageAll(worktreePath: string): Promise { + assertRegisteredWorktree(worktreePath); + + const git = simpleGit(worktreePath); + await git.add("-A"); +} + +/** + * Unstage a file (remove from staging area). + * + * Uses `git reset HEAD -- ` to unstage without + * discarding changes. + */ +export async function gitUnstageFile( + worktreePath: string, + filePath: string, +): Promise { + assertRegisteredWorktree(worktreePath); + assertValidGitPath(filePath); + + const git = simpleGit(worktreePath); + await git.reset(["HEAD", "--", filePath]); +} + +/** + * Unstage all files. + * + * Uses `git reset HEAD` to unstage all changes without + * discarding them. + */ +export async function gitUnstageAll(worktreePath: string): Promise { + assertRegisteredWorktree(worktreePath); + + const git = simpleGit(worktreePath); + await git.reset(["HEAD"]); +} diff --git a/apps/desktop/src/lib/trpc/routers/changes/security/index.ts b/apps/desktop/src/lib/trpc/routers/changes/security/index.ts new file mode 100644 index 00000000000..8fdb09c9e7a --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/changes/security/index.ts @@ -0,0 +1,31 @@ +/** + * Security module for changes routers. + * + * Security model: + * - PRIMARY: Worktree must be registered in localDb + * - SECONDARY: Paths validated for traversal attempts + * + * See path-validation.ts header for full threat model. + */ + +export { + gitCheckoutFile, + gitStageAll, + gitStageFile, + gitSwitchBranch, + gitUnstageAll, + gitUnstageFile, +} from "./git-commands"; + +export { + assertRegisteredWorktree, + assertValidGitPath, + getRegisteredWorktree, + PathValidationError, + type PathValidationErrorCode, + resolvePathInWorktree, + type ValidatePathOptions, + validateRelativePath, +} from "./path-validation"; + +export { secureFs } from "./secure-fs"; diff --git a/apps/desktop/src/lib/trpc/routers/changes/security/path-validation.ts b/apps/desktop/src/lib/trpc/routers/changes/security/path-validation.ts new file mode 100644 index 00000000000..72292859b02 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/changes/security/path-validation.ts @@ -0,0 +1,177 @@ +import { isAbsolute, normalize, resolve, sep } from "node:path"; +import { worktrees } from "@superset/local-db"; +import { eq } from "drizzle-orm"; +import { localDb } from "main/lib/local-db"; + +/** + * Security model for desktop app filesystem access: + * + * THREAT MODEL ASSUMPTION: + * A compromised renderer can already execute arbitrary commands via + * terminal panes. Therefore, filesystem-level symlink protections + * provide no meaningful security boundary—an attacker with renderer + * access can simply run `cat /etc/passwd` in a terminal. + * + * If your deployment exposes the renderer to untrusted content WITHOUT + * terminal access, this model does NOT apply and symlink escape checks + * should be re-enabled. + * + * PRIMARY BOUNDARY: assertRegisteredWorktree() + * - Only worktree paths registered in localDb are accessible via tRPC + * - Prevents direct filesystem access to unregistered paths + * + * SECONDARY: validateRelativePath() + * - Rejects absolute paths and ".." traversal segments + * - Defense in depth against path manipulation + * + * NOT IMPLEMENTED (intentional, see threat model above): + * - Symlink escape detection + * - Realpath resolution + */ + +/** + * Security error codes for path validation failures. + */ +export type PathValidationErrorCode = + | "ABSOLUTE_PATH" + | "PATH_TRAVERSAL" + | "UNREGISTERED_WORKTREE" + | "INVALID_TARGET"; + +/** + * Error thrown when path validation fails. + * Includes a code for programmatic handling. + */ +export class PathValidationError extends Error { + constructor( + message: string, + public readonly code: PathValidationErrorCode, + ) { + super(message); + this.name = "PathValidationError"; + } +} + +/** + * Validates that a worktree path is registered in localDb. + * This is THE critical security boundary. + * + * @throws PathValidationError if worktree is not registered + */ +export function assertRegisteredWorktree(worktreePath: string): void { + const exists = localDb + .select() + .from(worktrees) + .where(eq(worktrees.path, worktreePath)) + .get(); + + if (!exists) { + throw new PathValidationError( + "Worktree not registered in database", + "UNREGISTERED_WORKTREE", + ); + } +} + +/** + * Gets the worktree record if registered. Returns record for updates. + * + * @throws PathValidationError if worktree is not registered + */ +export function getRegisteredWorktree( + worktreePath: string, +): typeof worktrees.$inferSelect { + const worktree = localDb + .select() + .from(worktrees) + .where(eq(worktrees.path, worktreePath)) + .get(); + + if (!worktree) { + throw new PathValidationError( + "Worktree not registered in database", + "UNREGISTERED_WORKTREE", + ); + } + + return worktree; +} + +/** + * Options for path validation. + */ +export interface ValidatePathOptions { + /** + * Allow empty/root path (resolves to worktree itself). + * Default: false (prevents accidental worktree deletion) + */ + allowRoot?: boolean; +} + +/** + * Validates a relative file path for safety. + * Rejects absolute paths and path traversal attempts. + * + * @throws PathValidationError if path is invalid + */ +export function validateRelativePath( + filePath: string, + options: ValidatePathOptions = {}, +): void { + const { allowRoot = false } = options; + + // Reject absolute paths + if (isAbsolute(filePath)) { + throw new PathValidationError( + "Absolute paths are not allowed", + "ABSOLUTE_PATH", + ); + } + + const normalized = normalize(filePath); + const segments = normalized.split(sep); + + // Reject ".." as a path segment (allows "..foo" directories) + if (segments.includes("..")) { + throw new PathValidationError( + "Path traversal not allowed", + "PATH_TRAVERSAL", + ); + } + + // Reject root path unless explicitly allowed + if (!allowRoot && (normalized === "" || normalized === ".")) { + throw new PathValidationError( + "Cannot target worktree root", + "INVALID_TARGET", + ); + } +} + +/** + * Validates and resolves a path within a worktree. Sync, simple. + * + * @param worktreePath - The worktree base path + * @param filePath - The relative file path to validate + * @param options - Validation options + * @returns The resolved full path + * @throws PathValidationError if path is invalid + */ +export function resolvePathInWorktree( + worktreePath: string, + filePath: string, + options: ValidatePathOptions = {}, +): string { + validateRelativePath(filePath, options); + // Use resolve to handle any worktreePath (relative or absolute) + return resolve(worktreePath, normalize(filePath)); +} + +/** + * Validates a path for git commands. Lighter check that allows root. + * + * @throws PathValidationError if path is invalid + */ +export function assertValidGitPath(filePath: string): void { + validateRelativePath(filePath, { allowRoot: true }); +} diff --git a/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts b/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts new file mode 100644 index 00000000000..2e461dd731a --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts @@ -0,0 +1,110 @@ +import type { Stats } from "node:fs"; +import { lstat, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { + assertRegisteredWorktree, + resolvePathInWorktree, +} from "./path-validation"; + +/** + * Secure filesystem operations with built-in validation. + * + * Each operation: + * 1. Validates worktree is registered (security boundary) + * 2. Validates path doesn't escape worktree (defense in depth) + * 3. Performs the filesystem operation + * + * See path-validation.ts for the full security model and threat assumptions. + */ +export const secureFs = { + /** + * Read a file within a worktree. + */ + async readFile( + worktreePath: string, + filePath: string, + encoding: BufferEncoding = "utf-8", + ): Promise { + assertRegisteredWorktree(worktreePath); + const fullPath = resolvePathInWorktree(worktreePath, filePath); + return readFile(fullPath, encoding); + }, + + /** + * Read a file as a Buffer within a worktree. + */ + async readFileBuffer( + worktreePath: string, + filePath: string, + ): Promise { + assertRegisteredWorktree(worktreePath); + const fullPath = resolvePathInWorktree(worktreePath, filePath); + return readFile(fullPath); + }, + + /** + * Write content to a file within a worktree. + */ + async writeFile( + worktreePath: string, + filePath: string, + content: string, + ): Promise { + assertRegisteredWorktree(worktreePath); + const fullPath = resolvePathInWorktree(worktreePath, filePath); + await writeFile(fullPath, content, "utf-8"); + }, + + /** + * Delete a file or directory within a worktree. + * + * DANGEROUS: Uses recursive + force deletion. + * Explicitly prevents deleting the worktree root. + */ + async delete(worktreePath: string, filePath: string): Promise { + assertRegisteredWorktree(worktreePath); + // allowRoot: false prevents deleting the worktree itself + const fullPath = resolvePathInWorktree(worktreePath, filePath, { + allowRoot: false, + }); + await rm(fullPath, { recursive: true, force: true }); + }, + + /** + * Get file stats within a worktree. + * + * Uses `stat` (follows symlinks) to get the real file size. + */ + async stat(worktreePath: string, filePath: string): Promise { + assertRegisteredWorktree(worktreePath); + const fullPath = resolvePathInWorktree(worktreePath, filePath); + return stat(fullPath); + }, + + /** + * Get file stats without following symlinks. + * + * Use this when you need to know if something IS a symlink. + * For size checks, prefer `stat` instead. + */ + async lstat(worktreePath: string, filePath: string): Promise { + assertRegisteredWorktree(worktreePath); + const fullPath = resolvePathInWorktree(worktreePath, filePath); + return lstat(fullPath); + }, + + /** + * Check if a file exists within a worktree. + * + * Returns false for non-existent files and validation failures. + */ + async exists(worktreePath: string, filePath: string): Promise { + try { + assertRegisteredWorktree(worktreePath); + const fullPath = resolvePathInWorktree(worktreePath, filePath); + await stat(fullPath); + return true; + } catch { + return false; + } + }, +}; diff --git a/apps/desktop/src/lib/trpc/routers/changes/staging.ts b/apps/desktop/src/lib/trpc/routers/changes/staging.ts index 1d3109a65d8..83c06a489ec 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/staging.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/staging.ts @@ -1,8 +1,13 @@ -import { rm } from "node:fs/promises"; -import { join } from "node:path"; -import simpleGit from "simple-git"; import { z } from "zod"; import { publicProcedure, router } from "../.."; +import { + gitCheckoutFile, + gitStageAll, + gitStageFile, + gitUnstageAll, + gitUnstageFile, + secureFs, +} from "./security"; export const createStagingRouter = () => { return router({ @@ -14,8 +19,7 @@ export const createStagingRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - const git = simpleGit(input.worktreePath); - await git.add(input.filePath); + await gitStageFile(input.worktreePath, input.filePath); return { success: true }; }), @@ -27,8 +31,7 @@ export const createStagingRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - const git = simpleGit(input.worktreePath); - await git.reset(["HEAD", "--", input.filePath]); + await gitUnstageFile(input.worktreePath, input.filePath); return { success: true }; }), @@ -40,24 +43,21 @@ export const createStagingRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - const git = simpleGit(input.worktreePath); - await git.checkout(["--", input.filePath]); + await gitCheckoutFile(input.worktreePath, input.filePath); return { success: true }; }), stageAll: publicProcedure .input(z.object({ worktreePath: z.string() })) .mutation(async ({ input }): Promise<{ success: boolean }> => { - const git = simpleGit(input.worktreePath); - await git.add("-A"); + await gitStageAll(input.worktreePath); return { success: true }; }), unstageAll: publicProcedure .input(z.object({ worktreePath: z.string() })) .mutation(async ({ input }): Promise<{ success: boolean }> => { - const git = simpleGit(input.worktreePath); - await git.reset(["HEAD"]); + await gitUnstageAll(input.worktreePath); return { success: true }; }), @@ -69,8 +69,8 @@ export const createStagingRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - const fullPath = join(input.worktreePath, input.filePath); - await rm(fullPath, { recursive: true, force: true }); + // secureFs.delete validates path and checks for symlink escapes + await secureFs.delete(input.worktreePath, input.filePath); return { success: true }; }), }); diff --git a/apps/desktop/src/lib/trpc/routers/changes/status.ts b/apps/desktop/src/lib/trpc/routers/changes/status.ts index c547b98558c..9b79a161a71 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/status.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/status.ts @@ -1,9 +1,8 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; import type { ChangedFile, GitChangesStatus } from "shared/changes-types"; import simpleGit from "simple-git"; import { z } from "zod"; import { publicProcedure, router } from "../.."; +import { assertRegisteredWorktree, secureFs } from "./security"; import { applyNumstatToFiles } from "./utils/apply-numstat"; import { parseGitLog, @@ -21,6 +20,8 @@ export const createStatusRouter = () => { }), ) .query(async ({ input }): Promise => { + assertRegisteredWorktree(input.worktreePath); + const git = simpleGit(input.worktreePath); const defaultBranch = input.defaultBranch || "main"; @@ -64,6 +65,8 @@ export const createStatusRouter = () => { }), ) .query(async ({ input }): Promise => { + assertRegisteredWorktree(input.worktreePath); + const git = simpleGit(input.worktreePath); const nameStatus = await git.raw([ @@ -141,18 +144,34 @@ async function getBranchComparison( return { commits, againstBase, ahead, behind }; } +/** Max file size for line counting (1 MiB) - skip larger files to avoid OOM */ +const MAX_LINE_COUNT_SIZE = 1 * 1024 * 1024; + +/** + * Apply line counts to untracked files. + * + * Uses secureFs which: + * - Validates paths don't escape worktree + * - Uses stat (follows symlinks) for accurate size checks + * - Checks for symlink escapes + */ async function applyUntrackedLineCount( worktreePath: string, untracked: ChangedFile[], ): Promise { for (const file of untracked) { try { - const fullPath = join(worktreePath, file.path); - const content = await readFile(fullPath, "utf-8"); + // secureFs.stat uses stat (follows symlinks) for accurate size + const stats = await secureFs.stat(worktreePath, file.path); + if (stats.size > MAX_LINE_COUNT_SIZE) continue; + + const content = await secureFs.readFile(worktreePath, file.path); const lineCount = content.split("\n").length; file.additions = lineCount; file.deletions = 0; - } catch {} + } catch { + // Skip files that fail validation or reading + } } } diff --git a/apps/desktop/src/lib/trpc/routers/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts index ce624581628..8c2e5ed6e83 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/index.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts @@ -1,6 +1,13 @@ -import { settings, type TerminalPreset } from "@superset/local-db"; +import { + settings, + TERMINAL_LINK_BEHAVIORS, + type TerminalPreset, +} from "@superset/local-db"; import { localDb } from "main/lib/local-db"; -import { DEFAULT_CONFIRM_ON_QUIT } from "shared/constants"; +import { + DEFAULT_CONFIRM_ON_QUIT, + DEFAULT_TERMINAL_LINK_BEHAVIOR, +} from "shared/constants"; import { DEFAULT_RINGTONE_ID, RINGTONES } from "shared/ringtones"; import { z } from "zod"; import { publicProcedure, router } from "../.."; @@ -180,5 +187,25 @@ export const createSettingsRouter = () => { return { success: true }; }), + + getTerminalLinkBehavior: publicProcedure.query(() => { + const row = getSettings(); + return row.terminalLinkBehavior ?? DEFAULT_TERMINAL_LINK_BEHAVIOR; + }), + + setTerminalLinkBehavior: publicProcedure + .input(z.object({ behavior: z.enum(TERMINAL_LINK_BEHAVIORS) })) + .mutation(({ input }) => { + localDb + .insert(settings) + .values({ id: 1, terminalLinkBehavior: input.behavior }) + .onConflictDoUpdate({ + target: settings.id, + set: { terminalLinkBehavior: input.behavior }, + }) + .run(); + + return { success: true }; + }), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts index 0d2b8e87f55..77b32e36286 100644 --- a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts @@ -10,19 +10,37 @@ import { import { z } from "zod"; import { publicProcedure, router } from "../.."; +/** + * Zod schema for FileViewerState - matches shared/tabs-types.ts + */ +const fileViewerStateSchema = z.object({ + filePath: z.string(), + viewMode: z.enum(["rendered", "raw", "diff"]), + isLocked: z.boolean(), + diffLayout: z.enum(["inline", "side-by-side"]), + diffCategory: z + .enum(["against-main", "committed", "staged", "unstaged"]) + .optional(), + commitHash: z.string().optional(), + oldPath: z.string().optional(), +}); + /** * Zod schema for Pane */ const paneSchema = z.object({ id: z.string(), tabId: z.string(), - type: z.enum(["terminal", "webview"]), + type: z.enum(["terminal", "webview", "file-viewer"]), name: z.string(), isNew: z.boolean().optional(), needsAttention: z.boolean().optional(), initialCommands: z.array(z.string()).optional(), initialCwd: z.string().optional(), url: z.string().optional(), + cwd: z.string().nullable().optional(), + cwdConfirmed: z.boolean().optional(), + fileViewer: fileViewerStateSchema.optional(), }); /** diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx index f21aff0a34f..bdc930ec4f0 100644 --- a/apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx @@ -1,4 +1,12 @@ +import type { TerminalLinkBehavior } from "@superset/local-db"; import { Label } from "@superset/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@superset/ui/select"; import { Switch } from "@superset/ui/switch"; import { trpc } from "renderer/lib/trpc"; @@ -32,6 +40,37 @@ export function BehaviorSettings() { setConfirmOnQuit.mutate({ enabled }); }; + // Terminal link behavior setting + const { data: terminalLinkBehavior, isLoading: isLoadingLinkBehavior } = + trpc.settings.getTerminalLinkBehavior.useQuery(); + + const setTerminalLinkBehavior = + trpc.settings.setTerminalLinkBehavior.useMutation({ + onMutate: async ({ behavior }) => { + await utils.settings.getTerminalLinkBehavior.cancel(); + const previous = utils.settings.getTerminalLinkBehavior.getData(); + utils.settings.getTerminalLinkBehavior.setData(undefined, behavior); + return { previous }; + }, + onError: (_err, _vars, context) => { + if (context?.previous !== undefined) { + utils.settings.getTerminalLinkBehavior.setData( + undefined, + context.previous, + ); + } + }, + onSettled: () => { + utils.settings.getTerminalLinkBehavior.invalidate(); + }, + }); + + const handleLinkBehaviorChange = (value: string) => { + setTerminalLinkBehavior.mutate({ + behavior: value as TerminalLinkBehavior, + }); + }; + return (
@@ -58,6 +97,35 @@ export function BehaviorSettings() { disabled={isLoading || setConfirmOnQuit.isPending} />
+ +
+
+ +

+ Choose how to open file paths when Cmd+clicking in the terminal +

+
+ +
); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx new file mode 100644 index 00000000000..28b4abd9996 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx @@ -0,0 +1,149 @@ +import { Button } from "@superset/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { useMemo } from "react"; +import { HiMiniPlus, HiMiniXMark } from "react-icons/hi2"; +import { trpc } from "renderer/lib/trpc"; +import { useTabsStore } from "renderer/stores/tabs/store"; +import type { Tab } from "renderer/stores/tabs/types"; +import { getTabDisplayName } from "renderer/stores/tabs/utils"; + +interface GroupItemProps { + tab: Tab; + isActive: boolean; + needsAttention: boolean; + onSelect: () => void; + onClose: () => void; +} + +function GroupItem({ + tab, + isActive, + needsAttention, + onSelect, + onClose, +}: GroupItemProps) { + const displayName = getTabDisplayName(tab); + + return ( +
+ + + + + + {displayName} + + + +
+ ); +} + +export function GroupStrip() { + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + const activeWorkspaceId = activeWorkspace?.id; + + const allTabs = useTabsStore((s) => s.tabs); + const panes = useTabsStore((s) => s.panes); + const activeTabIds = useTabsStore((s) => s.activeTabIds); + const addTab = useTabsStore((s) => s.addTab); + const removeTab = useTabsStore((s) => s.removeTab); + const setActiveTab = useTabsStore((s) => s.setActiveTab); + + const tabs = useMemo( + () => + activeWorkspaceId + ? allTabs.filter((tab) => tab.workspaceId === activeWorkspaceId) + : [], + [activeWorkspaceId, allTabs], + ); + + const activeTabId = activeWorkspaceId + ? activeTabIds[activeWorkspaceId] + : null; + + // Check which tabs have panes that need attention + const tabsWithAttention = useMemo(() => { + const result = new Set(); + for (const pane of Object.values(panes)) { + if (pane.needsAttention) { + result.add(pane.tabId); + } + } + return result; + }, [panes]); + + const handleAddGroup = () => { + if (activeWorkspaceId) { + addTab(activeWorkspaceId); + } + }; + + const handleSelectGroup = (tabId: string) => { + if (activeWorkspaceId) { + setActiveTab(activeWorkspaceId, tabId); + } + }; + + const handleCloseGroup = (tabId: string) => { + removeTab(tabId); + }; + + return ( +
+ {tabs.length > 0 && ( +
+ {tabs.map((tab) => ( + handleSelectGroup(tab.id)} + onClose={() => handleCloseGroup(tab.id)} + /> + ))} +
+ )} + + + + + + New Group + + +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/index.ts new file mode 100644 index 00000000000..e905a6c8bad --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/index.ts @@ -0,0 +1 @@ +export { GroupStrip } from "./GroupStrip"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx new file mode 100644 index 00000000000..4a842517364 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx @@ -0,0 +1,624 @@ +import Editor, { type OnMount } from "@monaco-editor/react"; +import { Badge } from "@superset/ui/badge"; +import { ToggleGroup, ToggleGroupItem } from "@superset/ui/toggle-group"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import type * as Monaco from "monaco-editor"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + HiMiniLockClosed, + HiMiniLockOpen, + HiMiniPencil, + HiMiniXMark, +} from "react-icons/hi2"; +import { LuLoader } from "react-icons/lu"; +import { TbLayoutColumns, TbLayoutRows } from "react-icons/tb"; +import type { MosaicBranch } from "react-mosaic-component"; +import { MosaicWindow } from "react-mosaic-component"; +import { MarkdownRenderer } from "renderer/components/MarkdownRenderer"; +import { + monaco, + SUPERSET_THEME, + useMonacoReady, +} from "renderer/contexts/MonacoProvider"; +import { trpc } from "renderer/lib/trpc"; +import { useTabsStore } from "renderer/stores/tabs/store"; +import type { Pane } from "renderer/stores/tabs/types"; +import type { FileViewerMode } from "shared/tabs-types"; +import { DiffViewer } from "../../../ChangesContent/components/DiffViewer"; + +type SplitOrientation = "vertical" | "horizontal"; + +/** Client-side language detection for Monaco editor */ +function detectLanguage(filePath: string): string { + const ext = filePath.split(".").pop()?.toLowerCase() ?? ""; + const languageMap: Record = { + ts: "typescript", + tsx: "typescript", + js: "javascript", + jsx: "javascript", + mjs: "javascript", + cjs: "javascript", + json: "json", + md: "markdown", + mdx: "markdown", + css: "css", + scss: "scss", + less: "less", + html: "html", + xml: "xml", + yaml: "yaml", + yml: "yaml", + py: "python", + rs: "rust", + go: "go", + java: "java", + kt: "kotlin", + swift: "swift", + c: "c", + cpp: "cpp", + h: "c", + hpp: "cpp", + sh: "shell", + bash: "shell", + zsh: "shell", + sql: "sql", + graphql: "graphql", + gql: "graphql", + }; + return languageMap[ext] ?? "plaintext"; +} + +interface FileViewerPaneProps { + paneId: string; + path: MosaicBranch[]; + pane: Pane; + isActive: boolean; + tabId: string; + worktreePath: string; + splitPaneAuto: ( + tabId: string, + sourcePaneId: string, + dimensions: { width: number; height: number }, + path?: MosaicBranch[], + ) => void; + removePane: (paneId: string) => void; + setFocusedPane: (tabId: string, paneId: string) => void; +} + +export function FileViewerPane({ + paneId, + path, + pane, + isActive, + tabId, + worktreePath, + splitPaneAuto, + removePane, + setFocusedPane, +}: FileViewerPaneProps) { + const containerRef = useRef(null); + const [splitOrientation, setSplitOrientation] = + useState("vertical"); + const isMonacoReady = useMonacoReady(); + const editorRef = useRef(null); + const [isDirty, setIsDirty] = useState(false); + const originalContentRef = useRef(""); + // Store draft content to preserve edits across view mode switches + const draftContentRef = useRef(null); + const utils = trpc.useUtils(); + + // Track container dimensions for auto-split orientation + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const updateOrientation = () => { + const { width, height } = container.getBoundingClientRect(); + setSplitOrientation(width >= height ? "vertical" : "horizontal"); + }; + + updateOrientation(); + + const resizeObserver = new ResizeObserver(updateOrientation); + resizeObserver.observe(container); + + return () => { + resizeObserver.disconnect(); + }; + }, []); + + const fileViewer = pane.fileViewer; + + // Extract values with defaults for hooks (hooks must be called unconditionally) + const filePath = fileViewer?.filePath ?? ""; + const viewMode = fileViewer?.viewMode ?? "raw"; + const isLocked = fileViewer?.isLocked ?? false; + const diffCategory = fileViewer?.diffCategory; + const commitHash = fileViewer?.commitHash; + const oldPath = fileViewer?.oldPath; + + // Fetch branch info for against-main diffs (P1-1) + const { data: branchData } = trpc.changes.getBranches.useQuery( + { worktreePath }, + { enabled: !!worktreePath && diffCategory === "against-main" }, + ); + const effectiveBaseBranch = branchData?.defaultBranch ?? "main"; + + // Track if we're saving from raw mode to know when to clear draft + const savingFromRawRef = useRef(false); + + // Save mutation + const saveFileMutation = trpc.changes.saveFile.useMutation({ + onSuccess: () => { + setIsDirty(false); + // Update original content to current content after save + if (editorRef.current) { + originalContentRef.current = editorRef.current.getValue(); + } + // P1: Only clear draft if we saved from Raw mode (we saved the draft content) + // Don't clear if saving from Diff mode as that would discard Raw edits + if (savingFromRawRef.current) { + draftContentRef.current = null; + } + savingFromRawRef.current = false; + // Invalidate queries to refresh data + utils.changes.readWorkingFile.invalidate(); + utils.changes.getFileContents.invalidate(); + utils.changes.getStatus.invalidate(); + + // P1-2: Switch to unstaged view if saving from staged (edits become unstaged changes) + if (diffCategory === "staged") { + const panes = useTabsStore.getState().panes; + const currentPane = panes[paneId]; + if (currentPane?.fileViewer) { + useTabsStore.setState({ + panes: { + ...panes, + [paneId]: { + ...currentPane, + fileViewer: { + ...currentPane.fileViewer, + diffCategory: "unstaged", + }, + }, + }, + }); + } + } + }, + }); + + // Save handler for raw mode editor + const handleSaveRaw = useCallback(() => { + if (!editorRef.current || !filePath || !worktreePath) return; + // Mark that we're saving from Raw mode so onSuccess knows to clear draft + savingFromRawRef.current = true; + saveFileMutation.mutate({ + worktreePath, + filePath, + content: editorRef.current.getValue(), + }); + }, [worktreePath, filePath, saveFileMutation]); + + // Save handler for diff mode + const handleSaveDiff = useCallback( + (content: string) => { + if (!filePath || !worktreePath) return; + // Not saving from Raw mode - don't clear draft + savingFromRawRef.current = false; + saveFileMutation.mutate({ + worktreePath, + filePath, + content, + }); + }, + [worktreePath, filePath, saveFileMutation], + ); + + // Editor mount handler - set up Cmd+S keybinding + const handleEditorMount: OnMount = useCallback( + (editor) => { + editorRef.current = editor; + // Store original content for dirty tracking (only if not restoring draft) + // If we have draft content, originalContentRef is already set to the file content + if (!draftContentRef.current) { + originalContentRef.current = editor.getValue(); + } + // P1: Update dirty state based on restored draft content + setIsDirty(editor.getValue() !== originalContentRef.current); + + // Register save action with Cmd+S / Ctrl+S + editor.addAction({ + id: "save-file", + label: "Save File", + keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS], + run: () => { + handleSaveRaw(); + }, + }); + }, + [handleSaveRaw], + ); + + // Track content changes for dirty state + const handleEditorChange = useCallback((value: string | undefined) => { + if (value !== undefined) { + setIsDirty(value !== originalContentRef.current); + } + }, []); + + // Reset dirty state and draft when file changes + // biome-ignore lint/correctness/useExhaustiveDependencies: Reset on file change only + useEffect(() => { + setIsDirty(false); + originalContentRef.current = ""; + draftContentRef.current = null; + }, [filePath]); + + // Fetch raw file content - always call hook, use enabled to control fetching + const { data: rawFileData, isLoading: isLoadingRaw } = + trpc.changes.readWorkingFile.useQuery( + { worktreePath, filePath }, + { + enabled: + !!fileViewer && viewMode !== "diff" && !!filePath && !!worktreePath, + }, + ); + + // Fetch diff content - always call hook, use enabled to control fetching + const { data: diffData, isLoading: isLoadingDiff } = + trpc.changes.getFileContents.useQuery( + { + worktreePath, + filePath, + oldPath, + category: diffCategory ?? "unstaged", + commitHash, + // P1-1: Pass defaultBranch for against-main diffs + defaultBranch: + diffCategory === "against-main" ? effectiveBaseBranch : undefined, + }, + { + enabled: + !!fileViewer && + viewMode === "diff" && + !!diffCategory && + !!filePath && + !!worktreePath, + }, + ); + + // P1-1: Update originalContentRef when raw content loads (dirty tracking fix) + // biome-ignore lint/correctness/useExhaustiveDependencies: Only update baseline when content loads + useEffect(() => { + if (rawFileData?.ok === true && !isDirty) { + originalContentRef.current = rawFileData.content; + } + }, [rawFileData]); + + // Early return AFTER hooks + if (!fileViewer) { + return ( + path={path} title=""> +
+ No file viewer state +
+ + ); + } + + const handleFocus = () => { + setFocusedPane(tabId, paneId); + }; + + const handleClosePane = (e: React.MouseEvent) => { + e.stopPropagation(); + removePane(paneId); + }; + + const handleSplitPane = (e: React.MouseEvent) => { + e.stopPropagation(); + const container = containerRef.current; + if (!container) return; + + const { width, height } = container.getBoundingClientRect(); + splitPaneAuto(tabId, paneId, { width, height }, path); + }; + + const handleToggleLock = () => { + // Update the pane's lock state in the store + const panes = useTabsStore.getState().panes; + const currentPane = panes[paneId]; + if (currentPane?.fileViewer) { + useTabsStore.setState({ + panes: { + ...panes, + [paneId]: { + ...currentPane, + fileViewer: { + ...currentPane.fileViewer, + isLocked: !currentPane.fileViewer.isLocked, + }, + }, + }, + }); + } + }; + + const handleViewModeChange = (value: string) => { + if (!value) return; + const newMode = value as FileViewerMode; + + // P1: Save current editor content before switching away from raw mode + if (viewMode === "raw" && editorRef.current) { + draftContentRef.current = editorRef.current.getValue(); + } + + // Update the pane's view mode in the store + const panes = useTabsStore.getState().panes; + const currentPane = panes[paneId]; + if (currentPane?.fileViewer) { + useTabsStore.setState({ + panes: { + ...panes, + [paneId]: { + ...currentPane, + fileViewer: { + ...currentPane.fileViewer, + viewMode: newMode, + }, + }, + }, + }); + } + }; + + const fileName = filePath.split("/").pop() || filePath; + + // P1-3: Only allow editing for staged/unstaged diffs (not committed/against-main) + // P1: Also disable Diff editing when a Raw draft exists to prevent silent data loss + // User must go back to Raw mode to save their unsaved edits first + const hasDraft = draftContentRef.current !== null; + const isDiffEditable = + (diffCategory === "staged" || diffCategory === "unstaged") && !hasDraft; + + // Render content based on view mode + const renderContent = () => { + if (viewMode === "diff") { + if (isLoadingDiff) { + return ( +
+ Loading diff... +
+ ); + } + if (!diffData) { + return ( +
+ No diff available +
+ ); + } + return ( + + ); + } + + if (isLoadingRaw) { + return ( +
+ Loading... +
+ ); + } + + if (!rawFileData?.ok) { + const errorMessage = + rawFileData?.reason === "too-large" + ? "File is too large to preview" + : rawFileData?.reason === "binary" + ? "Binary file preview not supported" + : rawFileData?.reason === "outside-worktree" + ? "File is outside worktree" + : "File not found"; + return ( +
+ {errorMessage} +
+ ); + } + + if (viewMode === "rendered") { + return ( +
+ +
+ ); + } + + // Raw mode - editable Monaco editor + if (!isMonacoReady) { + return ( +
+ + Loading editor... +
+ ); + } + + // P0-2: Key by filePath to force remount and fresh action registration + // P1: Use draft content if available (preserves edits across view mode switches) + return ( + + + Loading editor... + + } + options={{ + minimap: { enabled: false }, + scrollBeyondLastLine: false, + wordWrap: "on", + fontSize: 13, + lineHeight: 20, + fontFamily: + "ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace", + padding: { top: 8, bottom: 8 }, + scrollbar: { + verticalScrollbarSize: 8, + horizontalScrollbarSize: 8, + }, + }} + /> + ); + }; + + // Determine which view modes are available + // P1-2: Include .mdx for consistency with default view mode logic + const isMarkdown = + filePath.endsWith(".md") || + filePath.endsWith(".markdown") || + filePath.endsWith(".mdx"); + const hasDiff = !!diffCategory; + + const splitIcon = + splitOrientation === "vertical" ? ( + + ) : ( + + ); + + // Show editable badge only for editable modes + const showEditableBadge = + viewMode === "raw" || (viewMode === "diff" && isDiffEditable); + const isSaving = saveFileMutation.isPending; + + return ( + + path={path} + title="" + renderToolbar={() => ( +
+
+ + {isDirty && } + {fileName} + + {showEditableBadge && ( + + + {isSaving ? "Saving..." : "⌘S"} + + )} +
+
+ + {isMarkdown && ( + + Rendered + + )} + + Raw + + {hasDiff && ( + + Diff + + )} + + + + + + + Split pane + + + + + + + + {isLocked + ? "Unlock (allow file replacement)" + : "Lock (prevent file replacement)"} + + + + + + + + Close + + +
+
+ )} + className={isActive ? "mosaic-window-focused" : ""} + > + {/* biome-ignore lint/a11y/useKeyWithClickEvents lint/a11y/noStaticElementInteractions: Focus handler */} +
+ {renderContent()} +
+ + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/index.ts new file mode 100644 index 00000000000..96c33fa0b12 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/index.ts @@ -0,0 +1 @@ +export { FileViewerPane } from "./FileViewerPane"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx index b6df3608e13..3502a82dce2 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx @@ -8,6 +8,7 @@ import { type MosaicNode, } from "react-mosaic-component"; import { dragDropManager } from "renderer/lib/dnd"; +import { trpc } from "renderer/lib/trpc"; import { useTabsStore } from "renderer/stores/tabs/store"; import type { Pane, Tab } from "renderer/stores/tabs/types"; import { @@ -15,6 +16,7 @@ import { extractPaneIdsFromLayout, getPaneIdsForTab, } from "renderer/stores/tabs/utils"; +import { FileViewerPane } from "./FileViewerPane"; import { TabPane } from "./TabPane"; interface TabViewProps { @@ -35,6 +37,10 @@ export function TabView({ tab, panes }: TabViewProps) { const movePaneToNewTab = useTabsStore((s) => s.movePaneToNewTab); const allTabs = useTabsStore((s) => s.tabs); + // Get worktree path for file viewer panes + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + const worktreePath = activeWorkspace?.worktreePath ?? ""; + // Get tabs in the same workspace for move targets const workspaceTabs = allTabs.filter( (t) => t.workspaceId === tab.workspaceId, @@ -90,6 +96,24 @@ export function TabView({ tab, panes }: TabViewProps) { ); } + // Route file-viewer panes to FileViewerPane component + if (pane.type === "file-viewer") { + return ( + + ); + } + + // Default: terminal panes return ( { const setTabAutoTitle = useTabsStore((s) => s.setTabAutoTitle); const updatePaneCwd = useTabsStore((s) => s.updatePaneCwd); const focusedPaneIds = useTabsStore((s) => s.focusedPaneIds); + const addFileViewerPane = useTabsStore((s) => s.addFileViewerPane); const terminalTheme = useTerminalTheme(); // Ref for initial theme to avoid recreating terminal on theme change @@ -68,6 +70,41 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const { data: workspaceCwd } = trpc.terminal.getWorkspaceCwd.useQuery(workspaceId); + // Query terminal link behavior setting + const { data: terminalLinkBehavior } = + trpc.settings.getTerminalLinkBehavior.useQuery(); + + // Handler for file link clicks - uses current setting value + const handleFileLinkClick = useCallback( + (path: string, line?: number, column?: number) => { + const behavior = terminalLinkBehavior ?? "external-editor"; + + if (behavior === "file-viewer") { + addFileViewerPane(workspaceId, { filePath: path }); + } else { + trpcClient.external.openFileInEditor + .mutate({ + path, + line, + column, + cwd: workspaceCwd ?? undefined, + }) + .catch((error) => { + console.error( + "[Terminal] Failed to open file in editor:", + path, + error, + ); + }); + } + }, + [terminalLinkBehavior, workspaceId, workspaceCwd, addFileViewerPane], + ); + + // Ref to avoid terminal recreation when callback changes + const handleFileLinkClickRef = useRef(handleFileLinkClick); + handleFileLinkClickRef.current = handleFileLinkClick; + // Seed cwd from initialCwd or workspace path (shell spawns there) // OSC-7 will override if/when the shell reports directory changes useEffect(() => { @@ -197,11 +234,12 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { xterm, fitAddon, cleanup: cleanupQuerySuppression, - } = createTerminalInstance( - container, - workspaceCwd, - initialThemeRef.current, - ); + } = createTerminalInstance(container, { + cwd: workspaceCwd, + initialTheme: initialThemeRef.current, + onFileLinkClick: (path, line, column) => + handleFileLinkClickRef.current(path, line, column), + }); xtermRef.current = xterm; fitAddonRef.current = fitAddon; isExitedRef.current = false; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts index 6058503b816..89473cdf9d6 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts @@ -92,19 +92,26 @@ function loadRenderer(xterm: XTerm): { dispose: () => void } { }; } +export interface CreateTerminalOptions { + cwd?: string; + initialTheme?: ITheme | null; + onFileLinkClick?: (path: string, line?: number, column?: number) => void; +} + export function createTerminalInstance( container: HTMLDivElement, - cwd?: string, - initialTheme?: ITheme | null, + options: CreateTerminalOptions = {}, ): { xterm: XTerm; fitAddon: FitAddon; cleanup: () => void; } { + const { cwd, initialTheme, onFileLinkClick } = options; + // Use provided theme, or fall back to localStorage-based default to prevent flash const theme = initialTheme ?? getDefaultTerminalTheme(); - const options = { ...TERMINAL_OPTIONS, theme }; - const xterm = new XTerm(options); + const terminalOptions = { ...TERMINAL_OPTIONS, theme }; + const xterm = new XTerm(terminalOptions); const fitAddon = new FitAddon(); const clipboardAddon = new ClipboardAddon(); @@ -142,20 +149,25 @@ export function createTerminalInstance( const filePathLinkProvider = new FilePathLinkProvider( xterm, (_event, path, line, column) => { - trpcClient.external.openFileInEditor - .mutate({ - path, - line, - column, - cwd, - }) - .catch((error) => { - console.error( - "[Terminal] Failed to open file in editor:", + if (onFileLinkClick) { + onFileLinkClick(path, line, column); + } else { + // Fallback to default behavior (external editor) + trpcClient.external.openFileInEditor + .mutate({ path, - error, - ); - }); + line, + column, + cwd, + }) + .catch((error) => { + console.error( + "[Terminal] Failed to open file in editor:", + path, + error, + ); + }); + } }, ); xterm.registerLinkProvider(filePathLinkProvider); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx index e04866eb656..8dc969d541a 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx @@ -2,6 +2,7 @@ import { useMemo } from "react"; import { trpc } from "renderer/lib/trpc"; import { useTabsStore } from "renderer/stores/tabs/store"; import { EmptyTabView } from "./EmptyTabView"; +import { GroupStrip } from "./GroupStrip"; import { TabView } from "./TabView"; export function TabsContent() { @@ -23,5 +24,12 @@ export function TabsContent() { return ; } - return ; + return ( +
+ +
+ +
+
+ ); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx index 1a2e20bd0fb..835e914fc05 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx @@ -1,11 +1,22 @@ -import { SidebarMode, useSidebarStore } from "renderer/stores"; +import { trpc } from "renderer/lib/trpc"; +import { useWorkspaceViewModeStore } from "renderer/stores/workspace-view-mode"; import { ChangesContent } from "./ChangesContent"; import { TabsContent } from "./TabsContent"; export function ContentView() { - const { currentMode } = useSidebarStore(); + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + const workspaceId = activeWorkspace?.id; - if (currentMode === SidebarMode.Changes) { + // Subscribe to the actual data, not just the getter function + const viewModeByWorkspaceId = useWorkspaceViewModeStore( + (s) => s.viewModeByWorkspaceId, + ); + + const viewMode = workspaceId + ? (viewModeByWorkspaceId[workspaceId] ?? "workbench") + : "workbench"; + + if (viewMode === "review") { return (
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/ChangesView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/ChangesView.tsx index ee0ec6d2e9c..b269533cc7f 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/ChangesView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/ChangesView.tsx @@ -6,13 +6,22 @@ import { HiMiniMinus, HiMiniPlus } from "react-icons/hi2"; import { trpc } from "renderer/lib/trpc"; import { useChangesStore } from "renderer/stores/changes"; import type { ChangeCategory, ChangedFile } from "shared/changes-types"; +import { PortsList } from "../TabsView/PortsList"; import { CategorySection } from "./components/CategorySection"; import { ChangesHeader } from "./components/ChangesHeader"; import { CommitInput } from "./components/CommitInput"; import { CommitItem } from "./components/CommitItem"; import { FileList } from "./components/FileList"; -export function ChangesView() { +interface ChangesViewProps { + onFileOpen?: ( + file: ChangedFile, + category: ChangeCategory, + commitHash?: string, + ) => void; +} + +export function ChangesView({ onFileOpen }: ChangesViewProps) { const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); const worktreePath = activeWorkspace?.worktreePath; @@ -128,11 +137,13 @@ export function ChangesView() { const handleFileSelect = (file: ChangedFile, category: ChangeCategory) => { if (!worktreePath) return; selectFile(worktreePath, file, category, null); + onFileOpen?.(file, category); }; const handleCommitFileSelect = (file: ChangedFile, commitHash: string) => { if (!worktreePath) return; selectFile(worktreePath, file, "committed", commitHash); + onFileOpen?.(file, "committed", commitHash); }; const handleCommitToggle = (hash: string) => { @@ -349,6 +360,8 @@ export function ChangesView() {
)} + +
); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx index 7511587610a..b93ef1f3849 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx @@ -1,29 +1,40 @@ -import { useSidebarStore } from "renderer/stores"; -import { SidebarMode } from "renderer/stores/sidebar-state"; +import { trpc } from "renderer/lib/trpc"; +import { useTabsStore } from "renderer/stores/tabs/store"; +import { useWorkspaceViewModeStore } from "renderer/stores/workspace-view-mode"; +import type { ChangeCategory, ChangedFile } from "shared/changes-types"; import { ChangesView } from "./ChangesView"; -import { ModeCarousel } from "./ModeCarousel"; -import { TabsView } from "./TabsView"; export function Sidebar() { - const { currentMode, setMode } = useSidebarStore(); + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + const workspaceId = activeWorkspace?.id; - const modes: SidebarMode[] = [SidebarMode.Tabs, SidebarMode.Changes]; + // Subscribe to the actual data, not just the getter function + const viewModeByWorkspaceId = useWorkspaceViewModeStore( + (s) => s.viewModeByWorkspaceId, + ); + + const viewMode = workspaceId + ? (viewModeByWorkspaceId[workspaceId] ?? "workbench") + : "workbench"; + + const addFileViewerPane = useTabsStore((s) => s.addFileViewerPane); + + // In Workbench mode, open files in FileViewerPane + const handleFileOpen = + viewMode === "workbench" && workspaceId + ? (file: ChangedFile, category: ChangeCategory, commitHash?: string) => { + addFileViewerPane(workspaceId, { + filePath: file.path, + diffCategory: category, + commitHash, + oldPath: file.oldPath, + }); + } + : undefined; return ( ); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/WorkspaceActionBar.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/WorkspaceActionBar.tsx index f641fde5af1..526bca55e10 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/WorkspaceActionBar.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/WorkspaceActionBar.tsx @@ -1,3 +1,4 @@ +import { ViewModeToggle } from "./components/ViewModeToggle"; import { WorkspaceActionBarLeft } from "./components/WorkspaceActionBarLeft"; import { WorkspaceActionBarRight } from "./components/WorkspaceActionBarRight"; @@ -9,11 +10,14 @@ export function WorkspaceActionBar({ worktreePath }: WorkspaceActionBarProps) { if (!worktreePath) return null; return ( -
+
-
+
+ +
+
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/ViewModeToggle/ViewModeToggle.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/ViewModeToggle/ViewModeToggle.tsx new file mode 100644 index 00000000000..00b93eaf6dc --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/ViewModeToggle/ViewModeToggle.tsx @@ -0,0 +1,57 @@ +import { cn } from "@superset/ui/utils"; +import { trpc } from "renderer/lib/trpc"; +import { + useWorkspaceViewModeStore, + type WorkspaceViewMode, +} from "renderer/stores/workspace-view-mode"; + +export function ViewModeToggle() { + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + const workspaceId = activeWorkspace?.id; + + const viewModeByWorkspaceId = useWorkspaceViewModeStore( + (s) => s.viewModeByWorkspaceId, + ); + const setWorkspaceViewMode = useWorkspaceViewModeStore( + (s) => s.setWorkspaceViewMode, + ); + + if (!workspaceId) return null; + + const currentMode = viewModeByWorkspaceId[workspaceId] ?? "workbench"; + + const handleModeChange = (mode: WorkspaceViewMode) => { + setWorkspaceViewMode(workspaceId, mode); + }; + + return ( +
+ + +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/ViewModeToggle/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/ViewModeToggle/index.ts new file mode 100644 index 00000000000..5e69ac17ef8 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/ViewModeToggle/index.ts @@ -0,0 +1 @@ +export { ViewModeToggle } from "./ViewModeToggle"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx index 661543c8580..a1d61bf3e53 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx @@ -3,6 +3,7 @@ import { trpc } from "renderer/lib/trpc"; import { useAppHotkey } from "renderer/stores/hotkeys"; import { useTabsStore } from "renderer/stores/tabs/store"; import { getNextPaneId, getPreviousPaneId } from "renderer/stores/tabs/utils"; +import { useWorkspaceViewModeStore } from "renderer/stores/workspace-view-mode"; import { ContentView } from "./ContentView"; import { ResizableSidebar } from "./ResizableSidebar"; import { WorkspaceActionBar } from "./WorkspaceActionBar"; @@ -39,16 +40,31 @@ export function WorkspaceView() { // Get focused pane ID for the active tab const focusedPaneId = activeTabId ? focusedPaneIds[activeTabId] : null; + // View mode for terminal creation - subscribe to actual data for reactivity + const viewModeByWorkspaceId = useWorkspaceViewModeStore( + (s) => s.viewModeByWorkspaceId, + ); + const setWorkspaceViewMode = useWorkspaceViewModeStore( + (s) => s.setWorkspaceViewMode, + ); + const viewMode = activeWorkspaceId + ? (viewModeByWorkspaceId[activeWorkspaceId] ?? "workbench") + : "workbench"; + // Tab management shortcuts useAppHotkey( "NEW_TERMINAL", () => { if (activeWorkspaceId) { + // If in Review mode, switch to Workbench first + if (viewMode === "review") { + setWorkspaceViewMode(activeWorkspaceId, "workbench"); + } addTab(activeWorkspaceId); } }, undefined, - [activeWorkspaceId, addTab], + [activeWorkspaceId, addTab, viewMode, setWorkspaceViewMode], ); useAppHotkey( diff --git a/apps/desktop/src/renderer/stores/index.ts b/apps/desktop/src/renderer/stores/index.ts index b289f0bfb38..886d0523e00 100644 --- a/apps/desktop/src/renderer/stores/index.ts +++ b/apps/desktop/src/renderer/stores/index.ts @@ -6,3 +6,4 @@ export * from "./ringtone"; export * from "./sidebar-state"; export * from "./tabs"; export * from "./theme"; +export * from "./workspace-view-mode"; diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index d28d8316add..f577db7d00b 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -4,9 +4,10 @@ import { create } from "zustand"; import { devtools, persist } from "zustand/middleware"; import { trpcTabsStorage } from "../../lib/trpc-storage"; import { movePaneToNewTab, movePaneToTab } from "./actions/move-pane"; -import type { TabsState, TabsStore } from "./types"; +import type { AddFileViewerPaneOptions, TabsState, TabsStore } from "./types"; import { type CreatePaneOptions, + createFileViewerPane, createPane, createTabWithPane, extractPaneIdsFromLayout, @@ -125,7 +126,11 @@ export const useTabsStore = create()( const paneIds = getPaneIdsForTab(state.panes, tabId); for (const paneId of paneIds) { - killTerminalForPane(paneId); + // Only kill terminal sessions for terminal panes (avoids unnecessary IPC for file-viewers) + const pane = state.panes[paneId]; + if (pane?.type === "terminal") { + killTerminalForPane(paneId); + } } const newPanes = { ...state.panes }; @@ -285,7 +290,10 @@ export const useTabsStore = create()( const newPanes = { ...state.panes }; for (const paneId of removedPaneIds) { - killTerminalForPane(paneId); + // P2: Only kill terminal for actual terminal panes (avoid unnecessary IPC) + if (state.panes[paneId]?.type === "terminal") { + killTerminalForPane(paneId); + } delete newPanes[paneId]; } @@ -340,6 +348,110 @@ export const useTabsStore = create()( return newPane.id; }, + addFileViewerPane: ( + workspaceId: string, + options: AddFileViewerPaneOptions, + ) => { + const state = get(); + const activeTabId = state.activeTabIds[workspaceId]; + const activeTab = state.tabs.find((t) => t.id === activeTabId); + + // If no active tab, create a new one (this shouldn't normally happen) + if (!activeTab) { + const { tabId, paneId } = get().addTab(workspaceId); + // Update the pane to be a file-viewer (must use set() to get fresh state after addTab) + const fileViewerPane = createFileViewerPane(tabId, options); + set((s) => ({ + panes: { + ...s.panes, + [paneId]: { + ...fileViewerPane, + id: paneId, // Keep the original ID + }, + }, + })); + return paneId; + } + + // Look for an existing unlocked file-viewer pane in the active tab + const tabPaneIds = extractPaneIdsFromLayout(activeTab.layout); + const fileViewerPanes = tabPaneIds + .map((id) => state.panes[id]) + .filter( + (p) => + p?.type === "file-viewer" && + p.fileViewer && + !p.fileViewer.isLocked, + ); + + // If we found an unlocked file-viewer pane, reuse it + if (fileViewerPanes.length > 0) { + const paneToReuse = fileViewerPanes[0]; + const fileName = + options.filePath.split("/").pop() || options.filePath; + + // Determine default view mode + let viewMode: "raw" | "rendered" | "diff" = "raw"; + if (options.diffCategory) { + viewMode = "diff"; + } else if ( + options.filePath.endsWith(".md") || + options.filePath.endsWith(".markdown") || + options.filePath.endsWith(".mdx") + ) { + viewMode = "rendered"; + } + + set({ + panes: { + ...state.panes, + [paneToReuse.id]: { + ...paneToReuse, + name: fileName, + fileViewer: { + filePath: options.filePath, + viewMode, + isLocked: false, + diffLayout: "inline", + diffCategory: options.diffCategory, + commitHash: options.commitHash, + oldPath: options.oldPath, + }, + }, + }, + focusedPaneIds: { + ...state.focusedPaneIds, + [activeTab.id]: paneToReuse.id, + }, + }); + + return paneToReuse.id; + } + + // No reusable pane found, create a new one + const newPane = createFileViewerPane(activeTab.id, options); + + const newLayout: MosaicNode = { + direction: "row", + first: activeTab.layout, + second: newPane.id, + splitPercentage: 50, + }; + + set({ + tabs: state.tabs.map((t) => + t.id === activeTab.id ? { ...t, layout: newLayout } : t, + ), + panes: { ...state.panes, [newPane.id]: newPane }, + focusedPaneIds: { + ...state.focusedPaneIds, + [activeTab.id]: newPane.id, + }, + }); + + return newPane.id; + }, + removePane: (paneId) => { const state = get(); const pane = state.panes[paneId]; @@ -354,7 +466,10 @@ export const useTabsStore = create()( return; } - killTerminalForPane(paneId); + // Only kill terminal sessions for terminal panes (avoids unnecessary IPC for file-viewers) + if (pane.type === "terminal") { + killTerminalForPane(paneId); + } const newLayout = removePaneFromLayout(tab.layout, paneId); if (!newLayout) { @@ -463,7 +578,19 @@ export const useTabsStore = create()( const sourcePane = state.panes[sourcePaneId]; if (!sourcePane || sourcePane.tabId !== tabId) return; - const newPane = createPane(tabId); + // Clone file-viewer panes instead of creating a terminal + const newPane = + sourcePane.type === "file-viewer" && sourcePane.fileViewer + ? createFileViewerPane(tabId, { + filePath: sourcePane.fileViewer.filePath, + viewMode: sourcePane.fileViewer.viewMode, + isLocked: true, // Lock the cloned pane + diffLayout: sourcePane.fileViewer.diffLayout, + diffCategory: sourcePane.fileViewer.diffCategory, + commitHash: sourcePane.fileViewer.commitHash, + oldPath: sourcePane.fileViewer.oldPath, + }) + : createPane(tabId); let newLayout: MosaicNode; if (path && path.length > 0) { @@ -511,7 +638,19 @@ export const useTabsStore = create()( const sourcePane = state.panes[sourcePaneId]; if (!sourcePane || sourcePane.tabId !== tabId) return; - const newPane = createPane(tabId); + // Clone file-viewer panes instead of creating a terminal + const newPane = + sourcePane.type === "file-viewer" && sourcePane.fileViewer + ? createFileViewerPane(tabId, { + filePath: sourcePane.fileViewer.filePath, + viewMode: sourcePane.fileViewer.viewMode, + isLocked: true, // Lock the cloned pane + diffLayout: sourcePane.fileViewer.diffLayout, + diffCategory: sourcePane.fileViewer.diffCategory, + commitHash: sourcePane.fileViewer.commitHash, + oldPath: sourcePane.fileViewer.oldPath, + }) + : createPane(tabId); let newLayout: MosaicNode; if (path && path.length > 0) { diff --git a/apps/desktop/src/renderer/stores/tabs/types.ts b/apps/desktop/src/renderer/stores/tabs/types.ts index bcb0f70af82..03fc45d921b 100644 --- a/apps/desktop/src/renderer/stores/tabs/types.ts +++ b/apps/desktop/src/renderer/stores/tabs/types.ts @@ -1,4 +1,5 @@ import type { MosaicBranch, MosaicNode } from "react-mosaic-component"; +import type { ChangeCategory } from "shared/changes-types"; import type { BaseTab, BaseTabsState, Pane, PaneType } from "shared/tabs-types"; // Re-export shared types @@ -28,6 +29,16 @@ export interface AddTabOptions { initialCwd?: string; } +/** + * Options for opening a file in a file-viewer pane + */ +export interface AddFileViewerPaneOptions { + filePath: string; + diffCategory?: ChangeCategory; + commitHash?: string; + oldPath?: string; +} + /** * Actions available on the tabs store */ @@ -51,6 +62,10 @@ export interface TabsStore extends TabsState { // Pane operations addPane: (tabId: string, options?: AddTabOptions) => string; + addFileViewerPane: ( + workspaceId: string, + options: AddFileViewerPaneOptions, + ) => string; removePane: (paneId: string) => void; setFocusedPane: (tabId: string, paneId: string) => void; markPaneAsUsed: (paneId: string) => void; diff --git a/apps/desktop/src/renderer/stores/tabs/utils.ts b/apps/desktop/src/renderer/stores/tabs/utils.ts index a1e7bef16cf..797e644d0c8 100644 --- a/apps/desktop/src/renderer/stores/tabs/utils.ts +++ b/apps/desktop/src/renderer/stores/tabs/utils.ts @@ -1,4 +1,10 @@ import type { MosaicBranch, MosaicNode } from "react-mosaic-component"; +import type { ChangeCategory } from "shared/changes-types"; +import type { + DiffLayout, + FileViewerMode, + FileViewerState, +} from "shared/tabs-types"; import type { Pane, PaneType, Tab } from "./types"; /** @@ -82,6 +88,62 @@ export const createPane = ( }; }; +/** + * Options for creating a file-viewer pane + */ +export interface CreateFileViewerPaneOptions { + filePath: string; + viewMode?: FileViewerMode; + isLocked?: boolean; + diffLayout?: DiffLayout; + diffCategory?: ChangeCategory; + commitHash?: string; + oldPath?: string; +} + +/** + * Creates a new file-viewer pane with the given properties + */ +export const createFileViewerPane = ( + tabId: string, + options: CreateFileViewerPaneOptions, +): Pane => { + const id = generateId("pane"); + + // Determine default view mode based on file and category + let defaultViewMode: FileViewerMode = "raw"; + if (options.diffCategory) { + defaultViewMode = "diff"; + } else if ( + options.filePath.endsWith(".md") || + options.filePath.endsWith(".markdown") || + options.filePath.endsWith(".mdx") + ) { + defaultViewMode = "rendered"; + } + + const fileViewer: FileViewerState = { + filePath: options.filePath, + viewMode: options.viewMode ?? defaultViewMode, + isLocked: options.isLocked ?? false, + diffLayout: options.diffLayout ?? "inline", + diffCategory: options.diffCategory, + commitHash: options.commitHash, + oldPath: options.oldPath, + }; + + // Use filename for display name + const fileName = options.filePath.split("/").pop() || options.filePath; + + return { + id, + tabId, + type: "file-viewer", + name: fileName, + fileViewer, + }; +}; + /** * Generates a static tab name based on existing tabs * (e.g., "Terminal 1", "Terminal 2", finding the next available number) diff --git a/apps/desktop/src/renderer/stores/workspace-view-mode.ts b/apps/desktop/src/renderer/stores/workspace-view-mode.ts new file mode 100644 index 00000000000..b9bee9b2665 --- /dev/null +++ b/apps/desktop/src/renderer/stores/workspace-view-mode.ts @@ -0,0 +1,56 @@ +import { create } from "zustand"; +import { devtools, persist } from "zustand/middleware"; + +/** + * Workspace view modes: + * - "workbench": Groups + Mosaic panes layout for in-flow work + * - "review": Dedicated Changes page for focused code review + */ +export type WorkspaceViewMode = "workbench" | "review"; + +interface WorkspaceViewModeState { + /** + * Per-workspace view mode. Defaults to "workbench" when not set. + */ + viewModeByWorkspaceId: Record; + + /** + * Get the view mode for a workspace, defaulting to "workbench" + */ + getWorkspaceViewMode: (workspaceId: string) => WorkspaceViewMode; + + /** + * Set the view mode for a workspace + */ + setWorkspaceViewMode: (workspaceId: string, mode: WorkspaceViewMode) => void; +} + +export const useWorkspaceViewModeStore = create()( + devtools( + persist( + (set, get) => ({ + viewModeByWorkspaceId: {}, + + getWorkspaceViewMode: (workspaceId: string) => { + return get().viewModeByWorkspaceId[workspaceId] ?? "workbench"; + }, + + setWorkspaceViewMode: ( + workspaceId: string, + mode: WorkspaceViewMode, + ) => { + set((state) => ({ + viewModeByWorkspaceId: { + ...state.viewModeByWorkspaceId, + [workspaceId]: mode, + }, + })); + }, + }), + { + name: "workspace-view-mode-store", + }, + ), + { name: "WorkspaceViewModeStore" }, + ), +); diff --git a/apps/desktop/src/shared/constants.ts b/apps/desktop/src/shared/constants.ts index 6bc788cead4..1ec904ae1fc 100644 --- a/apps/desktop/src/shared/constants.ts +++ b/apps/desktop/src/shared/constants.ts @@ -46,3 +46,4 @@ export const NOTIFICATION_EVENTS = { // Default user preference values export const DEFAULT_CONFIRM_ON_QUIT = true; +export const DEFAULT_TERMINAL_LINK_BEHAVIOR = "external-editor" as const; diff --git a/apps/desktop/src/shared/tabs-types.ts b/apps/desktop/src/shared/tabs-types.ts index 8ae323601eb..d38ce4c4284 100644 --- a/apps/desktop/src/shared/tabs-types.ts +++ b/apps/desktop/src/shared/tabs-types.ts @@ -3,10 +3,42 @@ * Renderer extends these with MosaicNode layout specifics. */ +import type { ChangeCategory } from "./changes-types"; + /** * Pane types that can be displayed within a tab */ -export type PaneType = "terminal" | "webview"; +export type PaneType = "terminal" | "webview" | "file-viewer"; + +/** + * File viewer display modes + */ +export type FileViewerMode = "rendered" | "raw" | "diff"; + +/** + * Diff layout options for file viewer + */ +export type DiffLayout = "inline" | "side-by-side"; + +/** + * File viewer pane-specific properties + */ +export interface FileViewerState { + /** Worktree-relative file path */ + filePath: string; + /** Display mode: rendered (markdown), raw (source), or diff */ + viewMode: FileViewerMode; + /** If true, this pane won't be reused for new file clicks */ + isLocked: boolean; + /** Diff display layout */ + diffLayout: DiffLayout; + /** Category for diff source (against-main, committed, staged, unstaged) */ + diffCategory?: ChangeCategory; + /** Commit hash for committed category diffs */ + commitHash?: string; + /** Original path for renamed files */ + oldPath?: string; +} /** * Base Pane interface - shared between main and renderer @@ -23,6 +55,7 @@ export interface Pane { url?: string; // For webview panes cwd?: string | null; // Current working directory cwdConfirmed?: boolean; // True if cwd confirmed via OSC-7, false if seeded + fileViewer?: FileViewerState; // For file-viewer panes } /** diff --git a/packages/local-db/drizzle/0004_add_terminal_link_behavior_setting.sql b/packages/local-db/drizzle/0004_add_terminal_link_behavior_setting.sql new file mode 100644 index 00000000000..ad70f21f3fe --- /dev/null +++ b/packages/local-db/drizzle/0004_add_terminal_link_behavior_setting.sql @@ -0,0 +1 @@ +ALTER TABLE `settings` ADD `terminal_link_behavior` text; \ No newline at end of file diff --git a/packages/local-db/drizzle/meta/0004_snapshot.json b/packages/local-db/drizzle/meta/0004_snapshot.json new file mode 100644 index 00000000000..991b5469eb5 --- /dev/null +++ b/packages/local-db/drizzle/meta/0004_snapshot.json @@ -0,0 +1,977 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "bb9f9f85-bcbb-4003-b20f-4c172a1c6fc8", + "prevId": "d5a52ac9-bc1e-4529-89bf-5748d4df5006", + "tables": { + "organization_members": { + "name": "organization_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organization_members_organization_id_idx": { + "name": "organization_members_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "organization_members_user_id_idx": { + "name": "organization_members_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "organization_members_organization_id_organizations_id_fk": { + "name": "organization_members_organization_id_organizations_id_fk", + "tableFrom": "organization_members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_members_user_id_users_id_fk": { + "name": "organization_members_user_id_users_id_fk", + "tableFrom": "organization_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "clerk_org_id": { + "name": "clerk_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "github_org": { + "name": "github_org", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organizations_clerk_org_id_unique": { + "name": "organizations_clerk_org_id_unique", + "columns": [ + "clerk_org_id" + ], + "isUnique": true + }, + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "organizations_clerk_org_id_idx": { + "name": "organizations_clerk_org_id_idx", + "columns": [ + "clerk_org_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "main_repo_path": { + "name": "main_repo_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_toast_dismissed": { + "name": "config_toast_dismissed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "projects_main_repo_path_idx": { + "name": "projects_main_repo_path_idx", + "columns": [ + "main_repo_path" + ], + "isUnique": false + }, + "projects_last_opened_at_idx": { + "name": "projects_last_opened_at_idx", + "columns": [ + "last_opened_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_app": { + "name": "last_used_app", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_presets": { + "name": "terminal_presets", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_presets_initialized": { + "name": "terminal_presets_initialized", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "selected_ringtone_id": { + "name": "selected_ringtone_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirm_on_quit": { + "name": "confirm_on_quit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_link_behavior": { + "name": "terminal_link_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_color": { + "name": "status_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_type": { + "name": "status_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_position": { + "name": "status_position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "estimate": { + "name": "estimate", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "due_date": { + "name": "due_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "labels": { + "name": "labels", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_provider": { + "name": "external_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_key": { + "name": "external_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tasks_slug_unique": { + "name": "tasks_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "tasks_slug_idx": { + "name": "tasks_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "tasks_organization_id_idx": { + "name": "tasks_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "tasks_assignee_id_idx": { + "name": "tasks_assignee_id_idx", + "columns": [ + "assignee_id" + ], + "isUnique": false + }, + "tasks_status_idx": { + "name": "tasks_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "tasks_created_at_idx": { + "name": "tasks_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tasks_organization_id_organizations_id_fk": { + "name": "tasks_organization_id_organizations_id_fk", + "tableFrom": "tasks", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_assignee_id_users_id_fk": { + "name": "tasks_assignee_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_creator_id_users_id_fk": { + "name": "tasks_creator_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "clerk_id": { + "name": "clerk_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_clerk_id_unique": { + "name": "users_clerk_id_unique", + "columns": [ + "clerk_id" + ], + "isUnique": true + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + "email" + ], + "isUnique": false + }, + "users_clerk_id_idx": { + "name": "users_clerk_id_idx", + "columns": [ + "clerk_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worktree_id": { + "name": "worktree_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspaces_project_id_idx": { + "name": "workspaces_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "workspaces_worktree_id_idx": { + "name": "workspaces_worktree_id_idx", + "columns": [ + "worktree_id" + ], + "isUnique": false + }, + "workspaces_last_opened_at_idx": { + "name": "workspaces_last_opened_at_idx", + "columns": [ + "last_opened_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_project_id_projects_id_fk": { + "name": "workspaces_project_id_projects_id_fk", + "tableFrom": "workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_worktree_id_worktrees_id_fk": { + "name": "workspaces_worktree_id_worktrees_id_fk", + "tableFrom": "workspaces", + "tableTo": "worktrees", + "columnsFrom": [ + "worktree_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_status": { + "name": "git_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_status": { + "name": "github_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "worktrees_project_id_idx": { + "name": "worktrees_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "worktrees_branch_idx": { + "name": "worktrees_branch_idx", + "columns": [ + "branch" + ], + "isUnique": false + } + }, + "foreignKeys": { + "worktrees_project_id_projects_id_fk": { + "name": "worktrees_project_id_projects_id_fk", + "tableFrom": "worktrees", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/local-db/drizzle/meta/_journal.json b/packages/local-db/drizzle/meta/_journal.json index 3117a6e2266..65dc59e762b 100644 --- a/packages/local-db/drizzle/meta/_journal.json +++ b/packages/local-db/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1766932805546, "tag": "0003_add_confirm_on_quit_setting", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1767166138761, + "tag": "0004_add_terminal_link_behavior_setting", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/local-db/src/schema/schema.ts b/packages/local-db/src/schema/schema.ts index 9b0639805f0..0f98d17ada9 100644 --- a/packages/local-db/src/schema/schema.ts +++ b/packages/local-db/src/schema/schema.ts @@ -5,6 +5,7 @@ import type { ExternalApp, GitHubStatus, GitStatus, + TerminalLinkBehavior, TerminalPreset, WorkspaceType, } from "./zod"; @@ -127,6 +128,9 @@ export const settings = sqliteTable("settings", { selectedRingtoneId: text("selected_ringtone_id"), activeOrganizationId: text("active_organization_id"), confirmOnQuit: integer("confirm_on_quit", { mode: "boolean" }), + terminalLinkBehavior: text( + "terminal_link_behavior", + ).$type(), }); export type InsertSettings = typeof settings.$inferInsert; diff --git a/packages/local-db/src/schema/zod.ts b/packages/local-db/src/schema/zod.ts index bb33e1d1596..ca8221e405d 100644 --- a/packages/local-db/src/schema/zod.ts +++ b/packages/local-db/src/schema/zod.ts @@ -96,3 +96,13 @@ export const EXTERNAL_APPS = [ ] as const; export type ExternalApp = (typeof EXTERNAL_APPS)[number]; + +/** + * Terminal link behavior options + */ +export const TERMINAL_LINK_BEHAVIORS = [ + "external-editor", + "file-viewer", +] as const; + +export type TerminalLinkBehavior = (typeof TERMINAL_LINK_BEHAVIORS)[number];