diff --git a/.gitignore b/.gitignore index b59b6739145..0bdd5ab0f21 100644 --- a/.gitignore +++ b/.gitignore @@ -15,8 +15,7 @@ release *.local # Editor directories and files -.vscode/.debug.env -.vscode/settings.json +.vscode .idea .DS_Store *.suo diff --git a/apps/desktop/.vscode/extensions.json b/apps/desktop/.vscode/extensions.json deleted file mode 100644 index ca1d0b1f7c4..00000000000 --- a/apps/desktop/.vscode/extensions.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "recommendations": ["biomejs.biome", "bradlc.vscode-tailwindcss"], - "unwantedRecommendations": [ - "dbaeumer.vscode-eslint", - "esbenp.prettier-vscode" - ] -} diff --git a/apps/desktop/.vscode/settings.json b/apps/desktop/.vscode/settings.json deleted file mode 100644 index 2426450377c..00000000000 --- a/apps/desktop/.vscode/settings.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "[javascript]": { - "editor.formatOnSave": true - }, - - "[json]": { - "editor.formatOnSave": true - }, - - "[typescript]": { - "editor.formatOnSave": true - }, - - "[typescriptreact]": { - "editor.formatOnSave": true - }, - - "biome.enabled": true, - - "editor.codeActionsOnSave": { - "source.fixAll.biome": "explicit" - }, - - "editor.defaultFormatter": "biomejs.biome", - - "editor.quickSuggestions": { - "strings": "on" - }, - - "files.associations": { - "*.css": "tailwindcss" - }, - - "tailwindCSS.experimental.configFile": "src/renderer/globals.css" -} diff --git a/apps/desktop/src/lib/trpc/routers/changes/branches.ts b/apps/desktop/src/lib/trpc/routers/changes/branches.ts new file mode 100644 index 00000000000..bdfed4d97ba --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/changes/branches.ts @@ -0,0 +1,159 @@ +import { db } from "main/lib/db"; +import simpleGit from "simple-git"; +import { z } from "zod"; +import { publicProcedure, router } from "../.."; + +export const createBranchesRouter = () => { + return router({ + getBranches: publicProcedure + .input(z.object({ worktreePath: z.string() })) + .query( + async ({ + input, + }): Promise<{ + local: Array<{ branch: string; lastCommitDate: number }>; + remote: string[]; + defaultBranch: string; + checkedOutBranches: Record; + }> => { + const git = simpleGit(input.worktreePath); + + const branchSummary = await git.branch(["-a"]); + + const localBranches: string[] = []; + const remote: string[] = []; + + for (const name of Object.keys(branchSummary.branches)) { + if (name.startsWith("remotes/origin/")) { + if (name === "remotes/origin/HEAD") continue; + const remoteName = name.replace("remotes/origin/", ""); + remote.push(remoteName); + } else { + localBranches.push(name); + } + } + + const local = await getLocalBranchesWithDates(git, localBranches); + const defaultBranch = await getDefaultBranch(git, remote); + const checkedOutBranches = await getCheckedOutBranches( + git, + input.worktreePath, + ); + + return { + local, + remote: remote.sort(), + defaultBranch, + checkedOutBranches, + }; + }, + ), + + switchBranch: publicProcedure + .input( + z.object({ + worktreePath: z.string(), + branch: z.string(), + }), + ) + .mutation(async ({ input }): Promise<{ success: boolean }> => { + const git = simpleGit(input.worktreePath); + + const worktree = db.data.worktrees.find( + (wt) => wt.path === input.worktreePath, + ); + if (!worktree) { + throw new Error(`No worktree found at path "${input.worktreePath}"`); + } + + await git.checkout(input.branch); + + await db.update((data) => { + const wt = data.worktrees.find((w) => w.path === input.worktreePath); + if (wt) { + wt.branch = input.branch; + if (wt.gitStatus) { + wt.gitStatus.branch = input.branch; + } + } + }); + + return { success: true }; + }), + }); +}; + +async function getLocalBranchesWithDates( + git: ReturnType, + localBranches: string[], +): Promise> { + try { + const branchInfo = await git.raw([ + "for-each-ref", + "--sort=-committerdate", + "--format=%(refname:short) %(committerdate:unix)", + "refs/heads/", + ]); + + const local: Array<{ branch: string; lastCommitDate: number }> = []; + for (const line of branchInfo.trim().split("\n")) { + if (!line) continue; + const lastSpaceIdx = line.lastIndexOf(" "); + const branch = line.substring(0, lastSpaceIdx); + const timestamp = Number.parseInt(line.substring(lastSpaceIdx + 1), 10); + if (localBranches.includes(branch)) { + local.push({ + branch, + lastCommitDate: timestamp * 1000, + }); + } + } + return local; + } catch { + return localBranches.map((branch) => ({ branch, lastCommitDate: 0 })); + } +} + +async function getDefaultBranch( + git: ReturnType, + remoteBranches: string[], +): Promise { + try { + const headRef = await git.raw(["symbolic-ref", "refs/remotes/origin/HEAD"]); + const match = headRef.match(/refs\/remotes\/origin\/(.+)/); + if (match) { + return match[1].trim(); + } + } catch { + if (remoteBranches.includes("master") && !remoteBranches.includes("main")) { + return "master"; + } + } + return "main"; +} + +async function getCheckedOutBranches( + git: ReturnType, + currentWorktreePath: string, +): Promise> { + const checkedOutBranches: Record = {}; + + try { + const worktreeList = await git.raw(["worktree", "list", "--porcelain"]); + const lines = worktreeList.split("\n"); + let currentPath: string | null = null; + + for (const line of lines) { + if (line.startsWith("worktree ")) { + currentPath = line.substring(9).trim(); + } else if (line.startsWith("branch ")) { + const branch = line.substring(7).trim().replace("refs/heads/", ""); + if (currentPath && currentPath !== currentWorktreePath) { + checkedOutBranches[branch] = currentPath; + } + } + } + } catch {} + + return checkedOutBranches; +} diff --git a/apps/desktop/src/lib/trpc/routers/changes/changes.ts b/apps/desktop/src/lib/trpc/routers/changes/changes.ts deleted file mode 100644 index 4c2f38a24ae..00000000000 --- a/apps/desktop/src/lib/trpc/routers/changes/changes.ts +++ /dev/null @@ -1,425 +0,0 @@ -import { readFile, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import type { - ChangedFile, - FileContents, - GitChangesStatus, -} from "shared/changes-types"; -import simpleGit from "simple-git"; -import { z } from "zod"; -import { publicProcedure, router } from "../.."; -import { - detectLanguage, - parseDiffNumstat, - parseGitLog, - parseGitStatus, - parseNameStatus, -} from "./utils/parse-status"; - -export const createChangesRouter = () => { - return router({ - getBranches: publicProcedure - .input(z.object({ worktreePath: z.string() })) - .query( - async ({ - input, - }): Promise<{ - local: string[]; - remote: string[]; - defaultBranch: string; - }> => { - const git = simpleGit(input.worktreePath); - - const branchSummary = await git.branch(["-a"]); - - const local: string[] = []; - const remote: string[] = []; - - for (const name of Object.keys(branchSummary.branches)) { - if (name.startsWith("remotes/origin/")) { - if (name === "remotes/origin/HEAD") continue; - const remoteName = name.replace("remotes/origin/", ""); - remote.push(remoteName); - } else { - local.push(name); - } - } - - let defaultBranch = "main"; - try { - const headRef = await git.raw([ - "symbolic-ref", - "refs/remotes/origin/HEAD", - ]); - const match = headRef.match(/refs\/remotes\/origin\/(.+)/); - if (match) { - defaultBranch = match[1].trim(); - } - } catch { - if (remote.includes("master") && !remote.includes("main")) { - defaultBranch = "master"; - } - } - - return { - local: local.sort(), - remote: remote.sort(), - defaultBranch, - }; - }, - ), - - getStatus: publicProcedure - .input( - z.object({ - worktreePath: z.string(), - defaultBranch: z.string().optional(), - }), - ) - .query(async ({ input }): Promise => { - const git = simpleGit(input.worktreePath); - const defaultBranch = input.defaultBranch || "main"; - - const status = await git.status(); - const parsed = parseGitStatus(status); - - let commits: GitChangesStatus["commits"] = []; - let againstMain: ChangedFile[] = []; - let ahead = 0; - let behind = 0; - - try { - const tracking = await git.raw([ - "rev-list", - "--left-right", - "--count", - `origin/${defaultBranch}...HEAD`, - ]); - const [behindStr, aheadStr] = tracking.trim().split(/\s+/); - behind = Number.parseInt(behindStr || "0", 10); - ahead = Number.parseInt(aheadStr || "0", 10); - - const logOutput = await git.raw([ - "log", - `origin/${defaultBranch}..HEAD`, - "--format=%H|%h|%s|%an|%aI", - ]); - commits = parseGitLog(logOutput); - - if (ahead > 0) { - const nameStatus = await git.raw([ - "diff", - "--name-status", - `origin/${defaultBranch}...HEAD`, - ]); - againstMain = parseNameStatus(nameStatus); - - const numstat = await git.raw([ - "diff", - "--numstat", - `origin/${defaultBranch}...HEAD`, - ]); - const stats = parseDiffNumstat(numstat); - for (const file of againstMain) { - const fileStat = stats.get(file.path); - if (fileStat) { - file.additions = fileStat.additions; - file.deletions = fileStat.deletions; - } - } - } - } catch {} - - if (parsed.staged.length > 0) { - try { - const stagedNumstat = await git.raw([ - "diff", - "--cached", - "--numstat", - ]); - const stagedStats = parseDiffNumstat(stagedNumstat); - for (const file of parsed.staged) { - const fileStat = stagedStats.get(file.path); - if (fileStat) { - file.additions = fileStat.additions; - file.deletions = fileStat.deletions; - } - } - } catch {} - } - - if (parsed.unstaged.length > 0) { - try { - const unstagedNumstat = await git.raw(["diff", "--numstat"]); - const unstagedStats = parseDiffNumstat(unstagedNumstat); - for (const file of parsed.unstaged) { - const fileStat = unstagedStats.get(file.path); - if (fileStat) { - file.additions = fileStat.additions; - file.deletions = fileStat.deletions; - } - } - } catch {} - } - - for (const file of parsed.untracked) { - try { - const fullPath = join(input.worktreePath, file.path); - const content = await readFile(fullPath, "utf-8"); - const lineCount = content.split("\n").length; - file.additions = lineCount; - file.deletions = 0; - } catch {} - } - - return { - branch: parsed.branch, - defaultBranch, - againstMain, - commits, - staged: parsed.staged, - unstaged: parsed.unstaged, - untracked: parsed.untracked, - ahead, - behind, - }; - }), - - getCommitFiles: publicProcedure - .input( - z.object({ - worktreePath: z.string(), - commitHash: z.string(), - }), - ) - .query(async ({ input }): Promise => { - const git = simpleGit(input.worktreePath); - - const nameStatus = await git.raw([ - "diff-tree", - "--no-commit-id", - "--name-status", - "-r", - input.commitHash, - ]); - const files = parseNameStatus(nameStatus); - - const numstat = await git.raw([ - "diff-tree", - "--no-commit-id", - "--numstat", - "-r", - input.commitHash, - ]); - const stats = parseDiffNumstat(numstat); - for (const file of files) { - const fileStat = stats.get(file.path); - if (fileStat) { - file.additions = fileStat.additions; - file.deletions = fileStat.deletions; - } - } - - return files; - }), - - getFileContents: publicProcedure - .input( - z.object({ - worktreePath: z.string(), - filePath: z.string(), - oldPath: z.string().optional(), - category: z.enum(["against-main", "committed", "staged", "unstaged"]), - commitHash: z.string().optional(), - defaultBranch: z.string().optional(), - }), - ) - .query(async ({ input }): Promise => { - const git = simpleGit(input.worktreePath); - const defaultBranch = input.defaultBranch || "main"; - const originalPath = input.oldPath || input.filePath; - let original = ""; - let modified = ""; - - switch (input.category) { - case "against-main": { - try { - original = await git.show([ - `origin/${defaultBranch}:${originalPath}`, - ]); - } catch { - original = ""; - } - try { - modified = await git.show([`HEAD:${input.filePath}`]); - } catch { - modified = ""; - } - break; - } - - case "committed": { - if (!input.commitHash) { - throw new Error("commitHash required for committed category"); - } - try { - original = await git.show([ - `${input.commitHash}^:${originalPath}`, - ]); - } catch { - original = ""; - } - try { - modified = await git.show([ - `${input.commitHash}:${input.filePath}`, - ]); - } catch { - modified = ""; - } - break; - } - - case "staged": { - try { - original = await git.show([`HEAD:${originalPath}`]); - } catch { - original = ""; - } - try { - modified = await git.show([`:0:${input.filePath}`]); - } catch { - modified = ""; - } - break; - } - - case "unstaged": { - try { - original = await git.show([`:0:${originalPath}`]); - } catch { - try { - original = await git.show([`HEAD:${originalPath}`]); - } catch { - original = ""; - } - } - try { - modified = await readFile( - join(input.worktreePath, input.filePath), - "utf-8", - ); - } catch { - modified = ""; - } - break; - } - } - - return { - original, - modified, - language: detectLanguage(input.filePath), - }; - }), - - stageFile: publicProcedure - .input( - z.object({ - worktreePath: z.string(), - filePath: z.string(), - }), - ) - .mutation(async ({ input }): Promise<{ success: boolean }> => { - const git = simpleGit(input.worktreePath); - await git.add(input.filePath); - return { success: true }; - }), - - unstageFile: publicProcedure - .input( - z.object({ - worktreePath: z.string(), - filePath: z.string(), - }), - ) - .mutation(async ({ input }): Promise<{ success: boolean }> => { - const git = simpleGit(input.worktreePath); - await git.reset(["HEAD", "--", input.filePath]); - return { success: true }; - }), - - discardChanges: publicProcedure - .input( - z.object({ - worktreePath: z.string(), - filePath: z.string(), - }), - ) - .mutation(async ({ input }): Promise<{ success: boolean }> => { - const git = simpleGit(input.worktreePath); - try { - await git.checkout(["--", input.filePath]); - return { success: true }; - } catch (error) { - const message = - error instanceof Error ? error.message : String(error); - throw new Error(`Failed to discard changes: ${message}`); - } - }), - - stageAll: publicProcedure - .input(z.object({ worktreePath: z.string() })) - .mutation(async ({ input }): Promise<{ success: boolean }> => { - const git = simpleGit(input.worktreePath); - await git.add("-A"); - 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"]); - return { success: true }; - }), - - deleteUntracked: publicProcedure - .input( - z.object({ - worktreePath: z.string(), - filePath: z.string(), - }), - ) - .mutation(async ({ input }): Promise<{ success: boolean }> => { - const fullPath = join(input.worktreePath, input.filePath); - try { - await rm(fullPath, { recursive: true, force: true }); - return { success: true }; - } catch (error) { - const message = - error instanceof Error ? error.message : String(error); - throw new Error(`Failed to delete untracked path: ${message}`); - } - }), - - saveFile: publicProcedure - .input( - z.object({ - worktreePath: z.string(), - filePath: z.string(), - content: z.string(), - }), - ) - .mutation(async ({ input }): Promise<{ success: boolean }> => { - const fullPath = join(input.worktreePath, input.filePath); - try { - await writeFile(fullPath, input.content, "utf-8"); - return { success: true }; - } catch (error) { - const message = - error instanceof Error ? error.message : String(error); - throw new Error(`Failed to save file: ${message}`); - } - }), - }); -}; diff --git a/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts b/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts new file mode 100644 index 00000000000..d29026aac0b --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts @@ -0,0 +1,191 @@ +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 { detectLanguage } from "./utils/parse-status"; + +export const createFileContentsRouter = () => { + return router({ + getFileContents: publicProcedure + .input( + z.object({ + worktreePath: z.string(), + filePath: z.string(), + oldPath: z.string().optional(), + category: z.enum(["against-main", "committed", "staged", "unstaged"]), + commitHash: z.string().optional(), + defaultBranch: z.string().optional(), + }), + ) + .query(async ({ input }): Promise => { + const git = simpleGit(input.worktreePath); + const defaultBranch = input.defaultBranch || "main"; + const originalPath = input.oldPath || input.filePath; + + const { original, modified } = await getFileVersions( + git, + input.worktreePath, + input.filePath, + originalPath, + input.category, + defaultBranch, + input.commitHash, + ); + + return { + original, + modified, + language: detectLanguage(input.filePath), + }; + }), + + saveFile: publicProcedure + .input( + z.object({ + worktreePath: z.string(), + filePath: z.string(), + content: z.string(), + }), + ) + .mutation(async ({ input }): Promise<{ success: boolean }> => { + const fullPath = join(input.worktreePath, input.filePath); + await writeFile(fullPath, input.content, "utf-8"); + return { success: true }; + }), + }); +}; + +type DiffCategory = "against-main" | "committed" | "staged" | "unstaged"; + +interface FileVersions { + original: string; + modified: string; +} + +async function getFileVersions( + git: ReturnType, + worktreePath: string, + filePath: string, + originalPath: string, + category: DiffCategory, + defaultBranch: string, + commitHash?: string, +): Promise { + switch (category) { + case "against-main": + return getAgainstMainVersions(git, filePath, originalPath, defaultBranch); + + case "committed": + if (!commitHash) { + throw new Error("commitHash required for committed category"); + } + return getCommittedVersions(git, filePath, originalPath, commitHash); + + case "staged": + return getStagedVersions(git, filePath, originalPath); + + case "unstaged": + return getUnstagedVersions(git, worktreePath, filePath, originalPath); + } +} + +async function getAgainstMainVersions( + 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 = ""; + } + + return { original, modified }; +} + +async function getCommittedVersions( + git: ReturnType, + filePath: string, + 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 = ""; + } + + return { original, modified }; +} + +async function getStagedVersions( + git: ReturnType, + 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 = ""; + } + + return { original, modified }; +} + +async function getUnstagedVersions( + git: ReturnType, + worktreePath: string, + 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 { + modified = await readFile(join(worktreePath, filePath), "utf-8"); + } catch { + modified = ""; + } + + return { original, modified }; +} diff --git a/apps/desktop/src/lib/trpc/routers/changes/index.ts b/apps/desktop/src/lib/trpc/routers/changes/index.ts index 45add5282ce..ef56da13b30 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/index.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/index.ts @@ -1 +1,26 @@ -export { createChangesRouter } from "./changes"; +import { router } from "../.."; +import { createBranchesRouter } from "./branches"; +import { createFileContentsRouter } from "./file-contents"; +import { createStagingRouter } from "./staging"; +import { createStatusRouter } from "./status"; + +export const createChangesRouter = () => { + const branchesRouter = createBranchesRouter(); + const statusRouter = createStatusRouter(); + const fileContentsRouter = createFileContentsRouter(); + const stagingRouter = createStagingRouter(); + + return router({ + // Branch operations + ...branchesRouter._def.procedures, + + // Status operations + ...statusRouter._def.procedures, + + // File contents operations + ...fileContentsRouter._def.procedures, + + // Staging operations + ...stagingRouter._def.procedures, + }); +}; diff --git a/apps/desktop/src/lib/trpc/routers/changes/staging.ts b/apps/desktop/src/lib/trpc/routers/changes/staging.ts new file mode 100644 index 00000000000..1d3109a65d8 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/changes/staging.ts @@ -0,0 +1,77 @@ +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import simpleGit from "simple-git"; +import { z } from "zod"; +import { publicProcedure, router } from "../.."; + +export const createStagingRouter = () => { + return router({ + stageFile: publicProcedure + .input( + z.object({ + worktreePath: z.string(), + filePath: z.string(), + }), + ) + .mutation(async ({ input }): Promise<{ success: boolean }> => { + const git = simpleGit(input.worktreePath); + await git.add(input.filePath); + return { success: true }; + }), + + unstageFile: publicProcedure + .input( + z.object({ + worktreePath: z.string(), + filePath: z.string(), + }), + ) + .mutation(async ({ input }): Promise<{ success: boolean }> => { + const git = simpleGit(input.worktreePath); + await git.reset(["HEAD", "--", input.filePath]); + return { success: true }; + }), + + discardChanges: publicProcedure + .input( + z.object({ + worktreePath: z.string(), + filePath: z.string(), + }), + ) + .mutation(async ({ input }): Promise<{ success: boolean }> => { + const git = simpleGit(input.worktreePath); + await git.checkout(["--", 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"); + 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"]); + return { success: true }; + }), + + deleteUntracked: publicProcedure + .input( + z.object({ + worktreePath: z.string(), + filePath: z.string(), + }), + ) + .mutation(async ({ input }): Promise<{ success: boolean }> => { + const fullPath = join(input.worktreePath, input.filePath); + await rm(fullPath, { recursive: true, force: true }); + 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 new file mode 100644 index 00000000000..2dbcaf4d354 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/changes/status.ts @@ -0,0 +1,153 @@ +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 { applyNumstatToFiles } from "./utils/apply-numstat"; +import { + parseGitLog, + parseGitStatus, + parseNameStatus, +} from "./utils/parse-status"; + +export const createStatusRouter = () => { + return router({ + getStatus: publicProcedure + .input( + z.object({ + worktreePath: z.string(), + defaultBranch: z.string().optional(), + }), + ) + .query(async ({ input }): Promise => { + const git = simpleGit(input.worktreePath); + const defaultBranch = input.defaultBranch || "main"; + + const status = await git.status(); + const parsed = parseGitStatus(status); + + const branchComparison = await getBranchComparison(git, defaultBranch); + + await applyNumstatToFiles(git, parsed.staged, [ + "diff", + "--cached", + "--numstat", + ]); + + await applyNumstatToFiles(git, parsed.unstaged, ["diff", "--numstat"]); + + await applyUntrackedLineCount(input.worktreePath, parsed.untracked); + + return { + branch: parsed.branch, + defaultBranch, + againstMain: branchComparison.againstMain, + commits: branchComparison.commits, + staged: parsed.staged, + unstaged: parsed.unstaged, + untracked: parsed.untracked, + ahead: branchComparison.ahead, + behind: branchComparison.behind, + }; + }), + + getCommitFiles: publicProcedure + .input( + z.object({ + worktreePath: z.string(), + commitHash: z.string(), + }), + ) + .query(async ({ input }): Promise => { + const git = simpleGit(input.worktreePath); + + const nameStatus = await git.raw([ + "diff-tree", + "--no-commit-id", + "--name-status", + "-r", + input.commitHash, + ]); + const files = parseNameStatus(nameStatus); + + await applyNumstatToFiles(git, files, [ + "diff-tree", + "--no-commit-id", + "--numstat", + "-r", + input.commitHash, + ]); + + return files; + }), + }); +}; + +interface BranchComparison { + commits: GitChangesStatus["commits"]; + againstMain: ChangedFile[]; + ahead: number; + behind: number; +} + +async function getBranchComparison( + git: ReturnType, + defaultBranch: string, +): Promise { + let commits: GitChangesStatus["commits"] = []; + let againstMain: ChangedFile[] = []; + let ahead = 0; + let behind = 0; + + try { + const tracking = await git.raw([ + "rev-list", + "--left-right", + "--count", + `origin/${defaultBranch}...HEAD`, + ]); + const [behindStr, aheadStr] = tracking.trim().split(/\s+/); + behind = Number.parseInt(behindStr || "0", 10); + ahead = Number.parseInt(aheadStr || "0", 10); + + const logOutput = await git.raw([ + "log", + `origin/${defaultBranch}..HEAD`, + "--format=%H|%h|%s|%an|%aI", + ]); + commits = parseGitLog(logOutput); + + if (ahead > 0) { + const nameStatus = await git.raw([ + "diff", + "--name-status", + `origin/${defaultBranch}...HEAD`, + ]); + againstMain = parseNameStatus(nameStatus); + + await applyNumstatToFiles(git, againstMain, [ + "diff", + "--numstat", + `origin/${defaultBranch}...HEAD`, + ]); + } + } catch {} + + return { commits, againstMain, ahead, behind }; +} + +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"); + const lineCount = content.split("\n").length; + file.additions = lineCount; + file.deletions = 0; + } catch {} + } +} diff --git a/apps/desktop/src/lib/trpc/routers/changes/utils/apply-numstat.ts b/apps/desktop/src/lib/trpc/routers/changes/utils/apply-numstat.ts new file mode 100644 index 00000000000..b7ea2a5eec7 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/changes/utils/apply-numstat.ts @@ -0,0 +1,27 @@ +import type { ChangedFile } from "shared/changes-types"; +import type { SimpleGit } from "simple-git"; +import { parseDiffNumstat } from "./parse-status"; + +/** + * Fetches numstat for given diff arguments and applies stats to files + */ +export async function applyNumstatToFiles( + git: SimpleGit, + files: ChangedFile[], + diffArgs: string[], +): Promise { + if (files.length === 0) return; + + try { + const numstat = await git.raw(diffArgs); + const stats = parseDiffNumstat(numstat); + + for (const file of files) { + const fileStat = stats.get(file.path); + if (fileStat) { + file.additions = fileStat.additions; + file.deletions = fileStat.deletions; + } + } + } catch {} +} diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx index 2caf62873a0..b126120c90c 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx @@ -17,6 +17,7 @@ import { HiChevronUp, HiMiniFolderOpen, } from "react-icons/hi2"; +import { formatPathWithProject } from "renderer/lib/formatPath"; import { trpc } from "renderer/lib/trpc"; import { useOpenNew } from "renderer/react-query/projects"; import { useCreateWorkspace } from "renderer/react-query/workspaces"; @@ -43,30 +44,6 @@ function generateBranchFromTitle(title: string): string { .slice(0, 50); } -function formatPath( - path: string, - projectName: string, - homeDir: string | undefined, -): string { - const normalizedPath = path.replace(/\\/g, "/"); - const normalizedHome = homeDir ? homeDir.replace(/\\/g, "/") : null; - - let displayPath = normalizedPath; - if ( - normalizedHome && - (normalizedPath === normalizedHome || - normalizedPath.startsWith(`${normalizedHome}/`)) - ) { - displayPath = `~${normalizedPath.slice(normalizedHome.length)}`; - } else { - displayPath = normalizedPath.replace(/^\/(?:Users|home)\/[^/]+/, "~"); - } - - const escapedProjectName = projectName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const suffixPattern = new RegExp(`/${escapedProjectName}$`); - return displayPath.replace(suffixPattern, ""); -} - export function NewWorkspaceModal() { const isOpen = useNewWorkspaceModalOpen(); const closeModal = useCloseNewWorkspaceModal(); @@ -198,7 +175,10 @@ export function NewWorkspaceModal() {
{project.name}
- {formatPath(project.mainRepoPath, project.name, homeDir)} + { + formatPathWithProject(project.mainRepoPath, project.name, homeDir) + .display + }
{isSelected && } diff --git a/apps/desktop/src/renderer/components/OpenInButton/index.ts b/apps/desktop/src/renderer/components/OpenInButton/index.ts index a0fffc24752..5c3551ebe09 100644 --- a/apps/desktop/src/renderer/components/OpenInButton/index.ts +++ b/apps/desktop/src/renderer/components/OpenInButton/index.ts @@ -1,2 +1,2 @@ export type { OpenInButtonProps } from "./OpenInButton"; -export { OpenInButton } from "./OpenInButton"; +export { getAppOption, OpenInButton } from "./OpenInButton"; diff --git a/apps/desktop/src/renderer/lib/formatPath/formatPath.ts b/apps/desktop/src/renderer/lib/formatPath/formatPath.ts new file mode 100644 index 00000000000..b8fb0ddb38b --- /dev/null +++ b/apps/desktop/src/renderer/lib/formatPath/formatPath.ts @@ -0,0 +1,35 @@ +function normalizeSeparators(path: string): string { + return path.replace(/\\/g, "/"); +} + +export function shortenHomePath( + path: string, + homeDir: string | undefined, +): string { + const normalizedPath = normalizeSeparators(path); + const normalizedHome = homeDir ? normalizeSeparators(homeDir) : null; + + if ( + normalizedHome && + (normalizedPath === normalizedHome || + normalizedPath.startsWith(`${normalizedHome}/`)) + ) { + return `~${normalizedPath.slice(normalizedHome.length)}`; + } + + return normalizedPath.replace(/^\/(?:Users|home)\/[^/]+/, "~"); +} + +export function formatPathWithProject( + path: string, + projectName: string, + homeDir: string | undefined, +): { display: string; full: string } { + const fullPath = shortenHomePath(path, homeDir); + + const escapedProjectName = projectName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const suffixPattern = new RegExp(`/${escapedProjectName}$`); + const displayPath = fullPath.replace(suffixPattern, ""); + + return { display: displayPath, full: fullPath }; +} diff --git a/apps/desktop/src/renderer/lib/formatPath/index.ts b/apps/desktop/src/renderer/lib/formatPath/index.ts new file mode 100644 index 00000000000..67d7e377f2c --- /dev/null +++ b/apps/desktop/src/renderer/lib/formatPath/index.ts @@ -0,0 +1 @@ +export { formatPathWithProject, shortenHomePath } from "./formatPath"; diff --git a/apps/desktop/src/renderer/lib/formatRelativeTime/formatRelativeTime.test.ts b/apps/desktop/src/renderer/lib/formatRelativeTime/formatRelativeTime.test.ts new file mode 100644 index 00000000000..7424e33e990 --- /dev/null +++ b/apps/desktop/src/renderer/lib/formatRelativeTime/formatRelativeTime.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "bun:test"; +import { formatRelativeTime } from "./formatRelativeTime"; + +describe("formatRelativeTime", () => { + const NOW = 1700000000000; // Fixed timestamp for testing + const MINUTE = 60 * 1000; + const HOUR = 60 * MINUTE; + const DAY = 24 * HOUR; + + // Mock Date.now for consistent tests + const originalDateNow = Date.now; + const mockNow = () => { + Date.now = () => NOW; + }; + const restoreNow = () => { + Date.now = originalDateNow; + }; + + it('returns "now" for timestamps less than 1 minute ago', () => { + mockNow(); + expect(formatRelativeTime(NOW)).toBe("now"); + expect(formatRelativeTime(NOW - 30 * 1000)).toBe("now"); // 30 seconds ago + expect(formatRelativeTime(NOW - 59 * 1000)).toBe("now"); // 59 seconds ago + restoreNow(); + }); + + it("returns minutes for timestamps between 1-59 minutes ago", () => { + mockNow(); + expect(formatRelativeTime(NOW - 1 * MINUTE)).toBe("1m"); + expect(formatRelativeTime(NOW - 5 * MINUTE)).toBe("5m"); + expect(formatRelativeTime(NOW - 30 * MINUTE)).toBe("30m"); + expect(formatRelativeTime(NOW - 59 * MINUTE)).toBe("59m"); + restoreNow(); + }); + + it("returns hours for timestamps between 1-23 hours ago", () => { + mockNow(); + expect(formatRelativeTime(NOW - 1 * HOUR)).toBe("1h"); + expect(formatRelativeTime(NOW - 5 * HOUR)).toBe("5h"); + expect(formatRelativeTime(NOW - 12 * HOUR)).toBe("12h"); + expect(formatRelativeTime(NOW - 23 * HOUR)).toBe("23h"); + restoreNow(); + }); + + it("returns days for timestamps between 1-29 days ago", () => { + mockNow(); + expect(formatRelativeTime(NOW - 1 * DAY)).toBe("1d"); + expect(formatRelativeTime(NOW - 7 * DAY)).toBe("7d"); + expect(formatRelativeTime(NOW - 14 * DAY)).toBe("14d"); + expect(formatRelativeTime(NOW - 29 * DAY)).toBe("29d"); + restoreNow(); + }); + + it("returns months for timestamps 30+ days ago", () => { + mockNow(); + expect(formatRelativeTime(NOW - 30 * DAY)).toBe("1mo"); + expect(formatRelativeTime(NOW - 60 * DAY)).toBe("2mo"); + expect(formatRelativeTime(NOW - 90 * DAY)).toBe("3mo"); + expect(formatRelativeTime(NOW - 365 * DAY)).toBe("12mo"); + restoreNow(); + }); + + it("handles edge cases at boundaries", () => { + mockNow(); + // Just under 1 hour should show 59m + expect(formatRelativeTime(NOW - 59 * MINUTE - 59 * 1000)).toBe("59m"); + // Exactly 1 hour should show 1h + expect(formatRelativeTime(NOW - 60 * MINUTE)).toBe("1h"); + // Just under 1 day should show 23h + expect(formatRelativeTime(NOW - 23 * HOUR - 59 * MINUTE)).toBe("23h"); + // Exactly 1 day should show 1d + expect(formatRelativeTime(NOW - 24 * HOUR)).toBe("1d"); + restoreNow(); + }); +}); diff --git a/apps/desktop/src/renderer/lib/formatRelativeTime/formatRelativeTime.ts b/apps/desktop/src/renderer/lib/formatRelativeTime/formatRelativeTime.ts new file mode 100644 index 00000000000..a55ed489e2b --- /dev/null +++ b/apps/desktop/src/renderer/lib/formatRelativeTime/formatRelativeTime.ts @@ -0,0 +1,14 @@ +export function formatRelativeTime(timestamp: number): string { + const now = Date.now(); + const diffMs = now - timestamp; + const diffMinutes = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMinutes / 60); + const diffDays = Math.floor(diffHours / 24); + const diffMonths = Math.floor(diffDays / 30); + + if (diffMinutes < 1) return "now"; + if (diffMinutes < 60) return `${diffMinutes}m`; + if (diffHours < 24) return `${diffHours}h`; + if (diffDays < 30) return `${diffDays}d`; + return `${diffMonths}mo`; +} diff --git a/apps/desktop/src/renderer/lib/formatRelativeTime/index.ts b/apps/desktop/src/renderer/lib/formatRelativeTime/index.ts new file mode 100644 index 00000000000..4de8c02ccaa --- /dev/null +++ b/apps/desktop/src/renderer/lib/formatRelativeTime/index.ts @@ -0,0 +1 @@ +export { formatRelativeTime } from "./formatRelativeTime"; diff --git a/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx b/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx index 349ed02cb9b..41f0eb49c4a 100644 --- a/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx @@ -2,6 +2,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { useState } from "react"; import { HiExclamationTriangle } from "react-icons/hi2"; import { LuChevronUp, LuFolderGit, LuFolderOpen, LuX } from "react-icons/lu"; +import { formatPathWithProject } from "renderer/lib/formatPath"; import { trpc } from "renderer/lib/trpc"; import { useOpenNew } from "renderer/react-query/projects"; import { useCreateWorkspace } from "renderer/react-query/workspaces"; @@ -10,46 +11,6 @@ import { CloneRepoDialog } from "./CloneRepoDialog"; import { InitGitDialog } from "./InitGitDialog"; import { StartTopBar } from "./StartTopBar"; -/** - * Normalizes path separators to forward slashes for consistent handling - */ -function normalizeSeparators(path: string): string { - return path.replace(/\\/g, "/"); -} - -/** - * Formats a path for display, replacing the home directory with ~ and optionally - * removing the trailing project name directory. - * Handles both Unix and Windows paths. - */ -function formatPath( - path: string, - projectName: string, - homeDir: string | undefined, -): { display: string; full: string } { - // Normalize both path and homeDir to use forward slashes - const normalizedPath = normalizeSeparators(path); - const normalizedHome = homeDir ? normalizeSeparators(homeDir) : null; - - // Replace home directory with ~ if we know the home dir - let fullPath = normalizedPath; - if (normalizedHome && normalizedPath.startsWith(normalizedHome)) { - fullPath = `~${normalizedPath.slice(normalizedHome.length)}`; - } else { - // Fallback: try common Unix patterns if home dir not available - fullPath = normalizedPath.replace(/^\/(?:Users|home)\/[^/]+/, "~"); - } - - // Escape special regex characters in project name - const escapedProjectName = projectName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const suffixPattern = new RegExp(`/${escapedProjectName}$`); - - // Remove trailing project name directory if it matches - const displayPath = fullPath.replace(suffixPattern, ""); - - return { display: displayPath, full: fullPath }; -} - export function StartView() { const { data: recentProjects = [] } = trpc.projects.getRecents.useQuery(); const { data: homeDir } = trpc.window.getHomeDir.useQuery(); @@ -211,7 +172,7 @@ export function StartView() {
{displayedProjects.map((project) => { - const pathInfo = formatPath( + const pathInfo = formatPathWithProject( project.mainRepoPath, project.name, homeDir, diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx index 65b3f19412e..c5477e9a0e8 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx @@ -1,5 +1,4 @@ import { trpc } from "renderer/lib/trpc"; -import { WorkspaceHeader } from "../WorkspaceView/WorkspaceHeader"; import { HelpMenu } from "./HelpMenu"; import { SettingsButton } from "./SettingsButton"; import { SidebarControl } from "./SidebarControl"; @@ -8,7 +7,6 @@ import { WorkspacesTabs } from "./WorkspaceTabs"; export function TopBar() { const { data: platform } = trpc.window.getPlatform.useQuery(); - const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); const isMac = platform === "darwin"; return (
@@ -24,7 +22,6 @@ export function TopBar() {
- {!isMac && } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceHeader/WorkspaceHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceHeader/WorkspaceHeader.tsx index a872351f713..ca453c17c77 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceHeader/WorkspaceHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceHeader/WorkspaceHeader.tsx @@ -1,21 +1,36 @@ import { OpenInButton } from "renderer/components/OpenInButton"; +import { shortenHomePath } from "renderer/lib/formatPath"; +import { trpc } from "renderer/lib/trpc"; +import { BranchSelector } from "./components/BranchSelector"; interface WorkspaceHeaderProps { worktreePath: string | undefined; } export function WorkspaceHeader({ worktreePath }: WorkspaceHeaderProps) { - const folderName = worktreePath - ? worktreePath.split("/").filter(Boolean).pop() || worktreePath + const { data: homeDir } = trpc.window.getHomeDir.useQuery(); + const displayPath = worktreePath + ? shortenHomePath(worktreePath, homeDir) : null; + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + const currentBranch = activeWorkspace?.worktree?.branch; + return ( -
- +
+ {worktreePath && ( + + )} + {currentBranch && worktreePath && ( + + )}
); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceHeader/components/BranchSelector/BranchSelector.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceHeader/components/BranchSelector/BranchSelector.tsx new file mode 100644 index 00000000000..c7070b89e76 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceHeader/components/BranchSelector/BranchSelector.tsx @@ -0,0 +1,173 @@ +import { Button } from "@superset/ui/button"; +import { + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList, +} from "@superset/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; +import { toast } from "@superset/ui/sonner"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { useMemo, useRef, useState } from "react"; +import { GoGitBranch } from "react-icons/go"; +import { HiChevronDown } from "react-icons/hi2"; +import { formatRelativeTime } from "renderer/lib/formatRelativeTime"; +import { trpc } from "renderer/lib/trpc"; + +const PAGE_SIZE = 10; + +interface BranchSelectorProps { + worktreePath: string; + currentBranch: string; +} + +export function BranchSelector({ + worktreePath, + currentBranch, +}: BranchSelectorProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); + const listRef = useRef(null); + + const { data: branchData } = trpc.changes.getBranches.useQuery( + { worktreePath }, + { enabled: !!worktreePath }, + ); + + const switchBranch = trpc.changes.switchBranch.useMutation(); + const utils = trpc.useUtils(); + + const localBranches = branchData?.local ?? []; + const checkedOutBranches = branchData?.checkedOutBranches ?? {}; + + const sortedBranches = useMemo( + () => [ + ...localBranches.filter((b) => b.branch === currentBranch), + ...localBranches.filter( + (b) => b.branch in checkedOutBranches && b.branch !== currentBranch, + ), + ...localBranches.filter( + (b) => !(b.branch in checkedOutBranches) && b.branch !== currentBranch, + ), + ], + [localBranches, checkedOutBranches, currentBranch], + ); + + const filteredBranches = useMemo(() => { + if (!search) return sortedBranches; + const searchLower = search.toLowerCase(); + return sortedBranches.filter((b) => + b.branch.toLowerCase().includes(searchLower), + ); + }, [sortedBranches, search]); + + const visibleBranches = filteredBranches.slice(0, visibleCount); + const hasMore = filteredBranches.length > visibleCount; + + const handleBranchChange = async (branch: string) => { + if (branch === currentBranch) return; + setOpen(false); + try { + await switchBranch.mutateAsync({ worktreePath, branch }); + utils.workspaces.getActive.invalidate(); + utils.changes.getBranches.invalidate(); + toast.success(`Switched to branch "${branch}"`); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to switch branch", + ); + } + }; + + const handleOpenChange = (isOpen: boolean) => { + setOpen(isOpen); + if (!isOpen) { + setSearch(""); + setVisibleCount(PAGE_SIZE); + } + }; + + const handleShowMore = () => { + setVisibleCount((c) => c + PAGE_SIZE); + requestAnimationFrame(() => { + listRef.current?.scrollTo({ + top: listRef.current.scrollHeight, + behavior: "smooth", + }); + }); + }; + + if (sortedBranches.length === 0) { + return null; + } + + return ( + + + + + + + + + Switch to a different branch + + + + + + + No branches found + {visibleBranches.map(({ branch, lastCommitDate }) => { + const isCheckedOut = branch in checkedOutBranches; + const isCurrent = branch === currentBranch; + const timeLabel = isCurrent + ? "(current)" + : lastCommitDate > 0 + ? formatRelativeTime(lastCommitDate) + : ""; + return ( + handleBranchChange(branch)} + disabled={isCheckedOut} + className="flex items-center justify-between gap-2" + > + {branch} + + {isCheckedOut && !isCurrent + ? `(in use) ${timeLabel}` + : timeLabel} + + + ); + })} + + {hasMore && ( +
+ +
+ )} +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceHeader/components/BranchSelector/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceHeader/components/BranchSelector/index.ts new file mode 100644 index 00000000000..dc75fa312af --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceHeader/components/BranchSelector/index.ts @@ -0,0 +1 @@ +export { BranchSelector } from "./BranchSelector"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceHeader/components/PathDisplay/PathDisplay.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceHeader/components/PathDisplay/PathDisplay.tsx new file mode 100644 index 00000000000..c3b8e1fbda5 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceHeader/components/PathDisplay/PathDisplay.tsx @@ -0,0 +1,46 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { HiOutlineFolder } from "react-icons/hi2"; +import { getAppOption } from "renderer/components/OpenInButton"; +import { shortenHomePath } from "renderer/lib/formatPath"; +import { trpc } from "renderer/lib/trpc"; + +interface PathDisplayProps { + path: string; +} + +export function PathDisplay({ path }: PathDisplayProps) { + const { data: homeDir } = trpc.window.getHomeDir.useQuery(); + const displayPath = shortenHomePath(path, homeDir); + + const utils = trpc.useUtils(); + const { data: lastUsedApp = "cursor" } = + trpc.settings.getLastUsedApp.useQuery(); + + const openInApp = trpc.external.openInApp.useMutation({ + onSuccess: () => utils.settings.getLastUsedApp.invalidate(), + }); + + const currentApp = getAppOption(lastUsedApp); + + const handleClick = () => { + openInApp.mutate({ path, app: lastUsedApp }); + }; + + return ( + + + + + + Open in {currentApp.label} + + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceHeader/components/PathDisplay/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceHeader/components/PathDisplay/index.ts new file mode 100644 index 00000000000..4df414260a4 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceHeader/components/PathDisplay/index.ts @@ -0,0 +1 @@ +export { PathDisplay } from "./PathDisplay"; 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 3eff3e255e5..0ffead8ac19 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx @@ -12,6 +12,7 @@ import { useTabsStore } from "renderer/stores/tabs/store"; import { HOTKEYS } from "shared/hotkeys"; import { ContentView } from "./ContentView"; import { Sidebar } from "./Sidebar"; +import { WorkspaceHeader } from "./WorkspaceHeader"; export function WorkspaceView() { const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); @@ -106,31 +107,35 @@ export function WorkspaceView() { }, [isSidebarOpen]); return ( - - setSidebarSize(0)} - onExpand={() => setSidebarSize(15)} - onResize={setSidebarSize} +
+ + - {isSidebarOpen && } - - - -
+ setSidebarSize(0)} + onExpand={() => setSidebarSize(15)} + onResize={setSidebarSize} + > + {isSidebarOpen && } + + +
-
-
-
+ + +
); }