diff --git a/.cursor/commands/deslop.md b/.cursor/commands/deslop.md new file mode 100644 index 00000000000..d82835663f7 --- /dev/null +++ b/.cursor/commands/deslop.md @@ -0,0 +1,11 @@ +# Remove AI code slop + +Check the diff against main, and remove all AI generated slop introduced in this branch. + +This includes: +- Extra comments that a human wouldn't add or is inconsistent with the rest of the file +- Extra defensive checks or try/catch blocks that are abnormal for that area of the codebase (especially if called by trusted / validated codepaths) +- Casts to any to get around type issues +- Any other style that is inconsistent with the file + +Report at the end with only a 1-3 sentence summary of what you changed \ No newline at end of file diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index ad9e7280a6c..c5fe5576d3a 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -88,6 +88,15 @@ export default defineConfig({ }), ], + // Monaco editor worker configuration + worker: { + format: "es", + }, + + optimizeDeps: { + include: ["monaco-editor"], + }, + publicDir: resolve(resources, "public"), build: { diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 654612486d6..0c9889e73fb 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -33,6 +33,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@monaco-editor/react": "^4.7.0", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", "@superset/ui": "workspace:*", @@ -51,6 +52,7 @@ "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", "clsx": "^2.1.1", + "culori": "^4.0.2", "date-fns": "^4.1.0", "dnd-core": "^16.0.1", "dotenv": "^17.2.3", @@ -64,6 +66,7 @@ "line-column-path": "^3.0.0", "lodash": "^4.17.21", "lowdb": "^7.0.1", + "monaco-editor": "^0.55.1", "nanoid": "^5.1.6", "node-pty": "1.1.0-beta30", "react": "^19.1.1", @@ -90,6 +93,7 @@ "devDependencies": { "@biomejs/biome": "^2.3.8", "@tailwindcss/vite": "^4.0.9", + "@types/culori": "^4.0.1", "@types/http-proxy": "^1.17.17", "@types/lodash": "^4.17.20", "@types/node": "^24.9.1", diff --git a/apps/desktop/src/lib/trpc/routers/changes/changes.ts b/apps/desktop/src/lib/trpc/routers/changes/changes.ts new file mode 100644 index 00000000000..e1e57399320 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/changes/changes.ts @@ -0,0 +1,407 @@ +import { readFile, rm } 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 { + // Remote tracking may not exist + } + + 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 { + // numstat may fail for some file types + } + } + + 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 { + // numstat may fail for some file types + } + } + + 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(), + 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"; + let original = ""; + let modified = ""; + + switch (input.category) { + case "against-main": { + // Original: file at default branch + // Modified: file at HEAD + try { + original = await git.show([ + `origin/${defaultBranch}:${input.filePath}`, + ]); + } catch { + // File doesn't exist on default branch (new file) + original = ""; + } + try { + modified = await git.show([`HEAD:${input.filePath}`]); + } catch { + // File doesn't exist at HEAD (deleted) + modified = ""; + } + break; + } + + case "committed": { + // Original: file at parent commit + // Modified: file at specified commit + if (!input.commitHash) { + throw new Error("commitHash required for committed category"); + } + try { + original = await git.show([ + `${input.commitHash}^:${input.filePath}`, + ]); + } catch { + // No parent (first commit) or file didn't exist + original = ""; + } + try { + modified = await git.show([ + `${input.commitHash}:${input.filePath}`, + ]); + } catch { + // File was deleted in this commit + modified = ""; + } + break; + } + + case "staged": { + try { + original = await git.show([`HEAD:${input.filePath}`]); + } catch { + original = ""; + } + try { + modified = await git.show([`:0:${input.filePath}`]); + } catch { + modified = ""; + } + break; + } + + case "unstaged": { + try { + original = await git.show([`:0:${input.filePath}`]); + } catch { + try { + original = await git.show([`HEAD:${input.filePath}`]); + } 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}`); + } + }), + }); +}; diff --git a/apps/desktop/src/lib/trpc/routers/changes/index.ts b/apps/desktop/src/lib/trpc/routers/changes/index.ts new file mode 100644 index 00000000000..45add5282ce --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/changes/index.ts @@ -0,0 +1 @@ +export { createChangesRouter } from "./changes"; diff --git a/apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.test.ts b/apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.test.ts new file mode 100644 index 00000000000..d05a8920b1b --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.test.ts @@ -0,0 +1,323 @@ +import { describe, expect, test } from "bun:test"; +import { + detectLanguage, + parseDiffNumstat, + parseGitLog, + parseNameStatus, +} from "./parse-status"; + +describe("parseGitLog", () => { + test("parses basic log output", () => { + const logOutput = `abc123|abc|Initial commit|John Doe|2024-01-15T10:30:00Z +def456|def|Add feature|Jane Smith|2024-01-16T14:20:00Z`; + + const commits = parseGitLog(logOutput); + + expect(commits).toHaveLength(2); + expect(commits[0]).toEqual({ + hash: "abc123", + shortHash: "abc", + message: "Initial commit", + author: "John Doe", + date: new Date("2024-01-15T10:30:00Z"), + files: [], + }); + expect(commits[1]).toEqual({ + hash: "def456", + shortHash: "def", + message: "Add feature", + author: "Jane Smith", + date: new Date("2024-01-16T14:20:00Z"), + files: [], + }); + }); + + test("handles commit messages containing pipe characters", () => { + const logOutput = `abc123|abc|fix: handle edge case | add fallback|John Doe|2024-01-15T10:30:00Z`; + + const commits = parseGitLog(logOutput); + + expect(commits).toHaveLength(1); + expect(commits[0].message).toBe("fix: handle edge case | add fallback"); + expect(commits[0].author).toBe("John Doe"); + }); + + test("handles commit messages with multiple pipe characters", () => { + const logOutput = `abc123|abc|a | b | c | d|Author|2024-01-15T10:30:00Z`; + + const commits = parseGitLog(logOutput); + + expect(commits).toHaveLength(1); + expect(commits[0].message).toBe("a | b | c | d"); + }); + + test("returns empty array for empty input", () => { + expect(parseGitLog("")).toEqual([]); + expect(parseGitLog(" ")).toEqual([]); + expect(parseGitLog("\n\n")).toEqual([]); + }); + + test("skips malformed lines with fewer than 5 parts", () => { + const logOutput = `abc123|abc|message|author|2024-01-15T10:30:00Z +invalid|line +def456|def|another|person|2024-01-16T14:20:00Z`; + + const commits = parseGitLog(logOutput); + + expect(commits).toHaveLength(2); + expect(commits[0].hash).toBe("abc123"); + expect(commits[1].hash).toBe("def456"); + }); + + test("handles invalid date with fallback to current date", () => { + const logOutput = `abc123|abc|message|author|not-a-date`; + + const commits = parseGitLog(logOutput); + const now = new Date(); + + expect(commits).toHaveLength(1); + // Date should be close to now (within 1 second) + expect(Math.abs(commits[0].date.getTime() - now.getTime())).toBeLessThan( + 1000, + ); + }); + + test("trims whitespace from all fields", () => { + const logOutput = ` abc123 | abc | message | author | 2024-01-15T10:30:00Z `; + + const commits = parseGitLog(logOutput); + + expect(commits).toHaveLength(1); + expect(commits[0].hash).toBe("abc123"); + expect(commits[0].shortHash).toBe("abc"); + expect(commits[0].message).toBe("message"); + expect(commits[0].author).toBe("author"); + }); +}); + +describe("parseDiffNumstat", () => { + test("parses basic numstat output", () => { + const numstatOutput = `10 5 src/file1.ts +20 3 src/file2.ts`; + + const stats = parseDiffNumstat(numstatOutput); + + expect(stats.get("src/file1.ts")).toEqual({ additions: 10, deletions: 5 }); + expect(stats.get("src/file2.ts")).toEqual({ additions: 20, deletions: 3 }); + }); + + test("handles binary files with dash markers", () => { + const numstatOutput = `- - image.png +10 5 src/code.ts`; + + const stats = parseDiffNumstat(numstatOutput); + + expect(stats.get("image.png")).toEqual({ additions: 0, deletions: 0 }); + expect(stats.get("src/code.ts")).toEqual({ additions: 10, deletions: 5 }); + }); + + test("handles renamed files with arrow format", () => { + const numstatOutput = `5 2 old/path.ts => new/path.ts`; + + const stats = parseDiffNumstat(numstatOutput); + + // Should be accessible by both old and new paths + expect(stats.get("new/path.ts")).toEqual({ additions: 5, deletions: 2 }); + expect(stats.get("old/path.ts")).toEqual({ additions: 5, deletions: 2 }); + }); + + test("handles copied files with arrow format", () => { + const numstatOutput = `0 0 source.ts => copy.ts`; + + const stats = parseDiffNumstat(numstatOutput); + + expect(stats.get("copy.ts")).toEqual({ additions: 0, deletions: 0 }); + expect(stats.get("source.ts")).toEqual({ additions: 0, deletions: 0 }); + }); + + test("handles paths with spaces in rename format", () => { + const numstatOutput = `3 1 old file.ts => new file.ts`; + + const stats = parseDiffNumstat(numstatOutput); + + expect(stats.get("new file.ts")).toEqual({ additions: 3, deletions: 1 }); + expect(stats.get("old file.ts")).toEqual({ additions: 3, deletions: 1 }); + }); + + test("returns empty map for empty input", () => { + expect(parseDiffNumstat("").size).toBe(0); + expect(parseDiffNumstat(" ").size).toBe(0); + expect(parseDiffNumstat("\n\n").size).toBe(0); + }); + + test("skips lines without path", () => { + const numstatOutput = `10 5 +20 3 valid/path.ts`; + + const stats = parseDiffNumstat(numstatOutput); + + expect(stats.size).toBe(1); + expect(stats.get("valid/path.ts")).toEqual({ additions: 20, deletions: 3 }); + }); + + test("handles non-numeric additions/deletions gracefully", () => { + const numstatOutput = `abc xyz file.ts`; + + const stats = parseDiffNumstat(numstatOutput); + + expect(stats.get("file.ts")).toEqual({ additions: 0, deletions: 0 }); + }); +}); + +describe("parseNameStatus", () => { + test("parses added files", () => { + const nameStatus = `A src/new-file.ts`; + + const files = parseNameStatus(nameStatus); + + expect(files).toHaveLength(1); + expect(files[0]).toEqual({ + path: "src/new-file.ts", + oldPath: undefined, + status: "added", + additions: 0, + deletions: 0, + }); + }); + + test("parses deleted files", () => { + const nameStatus = `D src/removed.ts`; + + const files = parseNameStatus(nameStatus); + + expect(files).toHaveLength(1); + expect(files[0].status).toBe("deleted"); + }); + + test("parses modified files", () => { + const nameStatus = `M src/changed.ts`; + + const files = parseNameStatus(nameStatus); + + expect(files).toHaveLength(1); + expect(files[0].status).toBe("modified"); + }); + + test("parses renamed files with percentage", () => { + const nameStatus = `R100 old/name.ts new/name.ts`; + + const files = parseNameStatus(nameStatus); + + expect(files).toHaveLength(1); + expect(files[0]).toEqual({ + path: "new/name.ts", + oldPath: "old/name.ts", + status: "renamed", + additions: 0, + deletions: 0, + }); + }); + + test("parses copied files with percentage", () => { + const nameStatus = `C095 source.ts destination.ts`; + + const files = parseNameStatus(nameStatus); + + expect(files).toHaveLength(1); + expect(files[0]).toEqual({ + path: "destination.ts", + oldPath: "source.ts", + status: "copied", + additions: 0, + deletions: 0, + }); + }); + + test("parses multiple files", () => { + const nameStatus = `A added.ts +M modified.ts +D deleted.ts +R100 old.ts new.ts`; + + const files = parseNameStatus(nameStatus); + + expect(files).toHaveLength(4); + expect(files[0].status).toBe("added"); + expect(files[1].status).toBe("modified"); + expect(files[2].status).toBe("deleted"); + expect(files[3].status).toBe("renamed"); + }); + + test("returns empty array for empty input", () => { + expect(parseNameStatus("")).toEqual([]); + expect(parseNameStatus(" ")).toEqual([]); + }); + + test("handles unknown status codes as modified", () => { + const nameStatus = `U unmerged.ts`; + + const files = parseNameStatus(nameStatus); + + expect(files).toHaveLength(1); + expect(files[0].status).toBe("modified"); + }); +}); + +describe("detectLanguage", () => { + test("detects TypeScript files", () => { + expect(detectLanguage("file.ts")).toBe("typescript"); + expect(detectLanguage("file.tsx")).toBe("typescript"); + }); + + test("detects JavaScript files", () => { + expect(detectLanguage("file.js")).toBe("javascript"); + expect(detectLanguage("file.jsx")).toBe("javascript"); + expect(detectLanguage("file.mjs")).toBe("javascript"); + expect(detectLanguage("file.cjs")).toBe("javascript"); + }); + + test("detects web files", () => { + expect(detectLanguage("index.html")).toBe("html"); + expect(detectLanguage("styles.css")).toBe("css"); + expect(detectLanguage("styles.scss")).toBe("scss"); + }); + + test("detects data format files", () => { + expect(detectLanguage("config.json")).toBe("json"); + expect(detectLanguage("config.yaml")).toBe("yaml"); + expect(detectLanguage("config.yml")).toBe("yaml"); + expect(detectLanguage("config.xml")).toBe("xml"); + }); + + test("detects markdown files", () => { + expect(detectLanguage("README.md")).toBe("markdown"); + expect(detectLanguage("docs.mdx")).toBe("markdown"); + }); + + test("detects shell scripts", () => { + expect(detectLanguage("script.sh")).toBe("shell"); + expect(detectLanguage("script.bash")).toBe("shell"); + }); + + test("detects other programming languages", () => { + expect(detectLanguage("app.py")).toBe("python"); + expect(detectLanguage("main.go")).toBe("go"); + expect(detectLanguage("lib.rs")).toBe("rust"); + expect(detectLanguage("App.java")).toBe("java"); + }); + + test("returns plaintext for unknown extensions", () => { + expect(detectLanguage("file.unknown")).toBe("plaintext"); + expect(detectLanguage("noextension")).toBe("plaintext"); + }); + + test("handles case insensitivity", () => { + expect(detectLanguage("FILE.TS")).toBe("typescript"); + expect(detectLanguage("README.MD")).toBe("markdown"); + }); + + test("handles nested paths", () => { + expect(detectLanguage("src/components/Button.tsx")).toBe("typescript"); + expect(detectLanguage("deep/nested/path/config.json")).toBe("json"); + }); +}); diff --git a/apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.ts b/apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.ts new file mode 100644 index 00000000000..70a44087566 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.ts @@ -0,0 +1,282 @@ +import type { + ChangedFile, + CommitInfo, + FileStatus, + GitChangesStatus, +} from "shared/changes-types"; +import type { StatusResult } from "simple-git"; + +/** + * Maps git status codes to our FileStatus type + */ +function mapGitStatus(gitIndex: string, gitWorking: string): FileStatus { + if (gitIndex === "A" || gitWorking === "A") return "added"; + if (gitIndex === "D" || gitWorking === "D") return "deleted"; + if (gitIndex === "R") return "renamed"; + if (gitIndex === "C") return "copied"; + if (gitIndex === "?" || gitWorking === "?") return "untracked"; + return "modified"; +} + +/** + * Converts a simple-git FileStatusResult to our ChangedFile type + */ +function toChangedFile( + path: string, + gitIndex: string, + gitWorking: string, +): ChangedFile { + return { + path, + status: mapGitStatus(gitIndex, gitWorking), + additions: 0, // Will be populated separately if needed + deletions: 0, + }; +} + +/** + * Parses simple-git StatusResult into our GitChangesStatus format + */ +export function parseGitStatus( + status: StatusResult, +): Pick { + const staged: ChangedFile[] = []; + const unstaged: ChangedFile[] = []; + const untracked: ChangedFile[] = []; + + for (const file of status.files) { + const path = file.path; + const index = file.index; + const working = file.working_dir; + + if (index === "?" && working === "?") { + untracked.push(toChangedFile(path, index, working)); + continue; + } + + if (index && index !== " " && index !== "?") { + staged.push({ + path, + oldPath: file.path !== file.from ? file.from : undefined, + status: mapGitStatus(index, " "), + additions: 0, + deletions: 0, + }); + } + + if (working && working !== " " && working !== "?") { + unstaged.push({ + path, + status: mapGitStatus(" ", working), + additions: 0, + deletions: 0, + }); + } + } + + return { + branch: status.current || "HEAD", + staged, + unstaged, + untracked, + }; +} + +/** + * Parses git log output into CommitInfo array + * Format: hash|shortHash|message|author|date + * Note: Uses limit of 5 parts to preserve '|' characters in commit messages + */ +export function parseGitLog(logOutput: string): CommitInfo[] { + if (!logOutput.trim()) return []; + + const commits: CommitInfo[] = []; + const lines = logOutput.trim().split("\n"); + + for (const line of lines) { + if (!line.trim()) continue; + + // Split into exactly 5 parts to preserve '|' in messages + const parts = line.split("|"); + if (parts.length < 5) continue; + + const hash = parts[0]?.trim(); + const shortHash = parts[1]?.trim(); + const message = parts.slice(2, -2).join("|").trim(); + const author = parts[parts.length - 2]?.trim(); + const dateStr = parts[parts.length - 1]?.trim(); + + if (!hash || !shortHash) continue; + + let date: Date; + if (dateStr) { + const parsed = new Date(dateStr); + date = Number.isNaN(parsed.getTime()) ? new Date() : parsed; + } else { + date = new Date(); + } + + commits.push({ + hash, + shortHash, + message: message || "", + author: author || "", + date, + files: [], // Files are loaded lazily per commit + }); + } + + return commits; +} + +/** + * Parses git diff --numstat output to get addition/deletion counts + * Format: additions\tdeletions\tfilepath + * For renames/copies: additions\tdeletions\toldpath => newpath + */ +export function parseDiffNumstat( + numstatOutput: string, +): Map { + const stats = new Map(); + + for (const line of numstatOutput.trim().split("\n")) { + if (!line.trim()) continue; + + const [addStr, delStr, ...pathParts] = line.split("\t"); + const rawPath = pathParts.join("\t"); + if (!rawPath) continue; + + // Binary files show "-" for additions/deletions + const additions = addStr === "-" ? 0 : Number.parseInt(addStr, 10) || 0; + const deletions = delStr === "-" ? 0 : Number.parseInt(delStr, 10) || 0; + const statEntry = { additions, deletions }; + + const renameMatch = rawPath.match(/^(.+) => (.+)$/); + if (renameMatch) { + const oldPath = renameMatch[1]; + const newPath = renameMatch[2]; + stats.set(newPath, statEntry); + stats.set(oldPath, statEntry); + } else { + stats.set(rawPath, statEntry); + } + } + + return stats; +} + +/** + * Parses git diff --name-status output for a commit + * Format: status\tfilepath (or status\toldpath\tnewpath for renames) + */ +export function parseNameStatus(nameStatusOutput: string): ChangedFile[] { + const files: ChangedFile[] = []; + + for (const line of nameStatusOutput.trim().split("\n")) { + if (!line.trim()) continue; + + const parts = line.split("\t"); + const statusCode = parts[0]; + if (!statusCode) continue; + + const isRenameOrCopy = + statusCode.startsWith("R") || statusCode.startsWith("C"); + const path = isRenameOrCopy ? parts[2] : parts[1]; + const oldPath = isRenameOrCopy ? parts[1] : undefined; + + if (!path) continue; + + let status: FileStatus; + switch (statusCode[0]) { + case "A": + status = "added"; + break; + case "D": + status = "deleted"; + break; + case "R": + status = "renamed"; + break; + case "C": + status = "copied"; + break; + default: + status = "modified"; + } + + files.push({ + path, + oldPath, + status, + additions: 0, + deletions: 0, + }); + } + + return files; +} + +/** + * Detects Monaco language from file extension + */ +export function detectLanguage(filePath: string): string { + const ext = filePath.split(".").pop()?.toLowerCase(); + + const languageMap: Record = { + // JavaScript/TypeScript + ts: "typescript", + tsx: "typescript", + js: "javascript", + jsx: "javascript", + mjs: "javascript", + cjs: "javascript", + + // Web + html: "html", + htm: "html", + css: "css", + scss: "scss", + less: "less", + + // Data formats + json: "json", + yaml: "yaml", + yml: "yaml", + xml: "xml", + toml: "toml", + + // Markdown/Documentation + md: "markdown", + mdx: "markdown", + + // Shell + sh: "shell", + bash: "shell", + zsh: "shell", + fish: "shell", + + // Config + dockerfile: "dockerfile", + makefile: "makefile", + + // Other languages + py: "python", + rb: "ruby", + go: "go", + rs: "rust", + java: "java", + kt: "kotlin", + swift: "swift", + c: "c", + cpp: "cpp", + h: "c", + hpp: "cpp", + cs: "csharp", + php: "php", + sql: "sql", + graphql: "graphql", + gql: "graphql", + }; + + return languageMap[ext || ""] || "plaintext"; +} diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index 4571dbbda4b..0de8a79d347 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -1,5 +1,6 @@ import type { BrowserWindow } from "electron"; import { router } from ".."; +import { createChangesRouter } from "./changes"; import { createConfigRouter } from "./config"; import { createExternalRouter } from "./external"; import { createMenuRouter } from "./menu"; @@ -24,6 +25,7 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => { projects: createProjectsRouter(getWindow), workspaces: createWorkspacesRouter(), terminal: createTerminalRouter(), + changes: createChangesRouter(), notifications: createNotificationsRouter(), menu: createMenuRouter(), external: createExternalRouter(), diff --git a/apps/desktop/src/main/lib/app-state/index.ts b/apps/desktop/src/main/lib/app-state/index.ts index e736a541d23..0940a7be3f1 100644 --- a/apps/desktop/src/main/lib/app-state/index.ts +++ b/apps/desktop/src/main/lib/app-state/index.ts @@ -41,6 +41,11 @@ export const appState = new Proxy({} as AppStateDB, { if (!_appState) { throw new Error("App state not initialized. Call initAppState() first."); } - return _appState[prop as keyof AppStateDB]; + const value = _appState[prop as keyof AppStateDB]; + // Bind methods to the real instance to preserve correct `this` context + if (typeof value === "function") { + return value.bind(_appState); + } + return value; }, }); diff --git a/apps/desktop/src/renderer/contexts/AppProviders.tsx b/apps/desktop/src/renderer/contexts/AppProviders.tsx index d6687d18b67..abb72b78760 100644 --- a/apps/desktop/src/renderer/contexts/AppProviders.tsx +++ b/apps/desktop/src/renderer/contexts/AppProviders.tsx @@ -1,4 +1,5 @@ import type React from "react"; +import { MonacoProvider } from "./MonacoProvider"; import { TRPCProvider } from "./TRPCProvider"; interface AppProvidersProps { @@ -6,5 +7,9 @@ interface AppProvidersProps { } export function AppProviders({ children }: AppProvidersProps) { - return {children}; + return ( + + {children} + + ); } diff --git a/apps/desktop/src/renderer/contexts/MonacoProvider.tsx b/apps/desktop/src/renderer/contexts/MonacoProvider.tsx new file mode 100644 index 00000000000..7f6c98a8322 --- /dev/null +++ b/apps/desktop/src/renderer/contexts/MonacoProvider.tsx @@ -0,0 +1,66 @@ +import { loader } from "@monaco-editor/react"; +import * as monaco from "monaco-editor"; +import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker"; +import cssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker"; +import htmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker"; +import jsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker"; +import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker"; +import type React from "react"; +import { useEffect } from "react"; +import { useMonacoTheme } from "renderer/stores/theme"; + +self.MonacoEnvironment = { + getWorker(_: unknown, label: string) { + if (label === "json") { + return new jsonWorker(); + } + if (label === "css" || label === "scss" || label === "less") { + return new cssWorker(); + } + if (label === "html" || label === "handlebars" || label === "razor") { + return new htmlWorker(); + } + if (label === "typescript" || label === "javascript") { + return new tsWorker(); + } + return new editorWorker(); + }, +}; + +loader.config({ monaco }); + +const SUPERSET_THEME = "superset-theme"; + +let monacoInitialized = false; + +async function initializeMonaco(): Promise { + if (monacoInitialized) { + return monaco; + } + + await loader.init(); + monacoInitialized = true; + return monaco; +} + +const monacoPromise = initializeMonaco(); + +interface MonacoProviderProps { + children: React.ReactNode; +} + +export function MonacoProvider({ children }: MonacoProviderProps) { + const monacoTheme = useMonacoTheme(); + + useEffect(() => { + if (!monacoTheme) return; + + monacoPromise.then((monacoInstance) => { + monacoInstance.editor.defineTheme(SUPERSET_THEME, monacoTheme); + }); + }, [monacoTheme]); + + return <>{children}; +} + +export { monaco, SUPERSET_THEME }; diff --git a/apps/desktop/src/renderer/contexts/index.ts b/apps/desktop/src/renderer/contexts/index.ts index c34498c7203..5ffa9434eed 100644 --- a/apps/desktop/src/renderer/contexts/index.ts +++ b/apps/desktop/src/renderer/contexts/index.ts @@ -1,2 +1,3 @@ export { AppProviders } from "./AppProviders"; +export { MonacoProvider, SUPERSET_THEME } from "./MonacoProvider"; export { TRPCProvider } from "./TRPCProvider"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent.tsx deleted file mode 100644 index e61c3a4cb38..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent.tsx +++ /dev/null @@ -1,16 +0,0 @@ -export function ChangesContent() { - return ( -
-
-
-
-

- Changes -

-

Coming soon...

-
-
-
-
- ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/ChangesContent.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/ChangesContent.tsx new file mode 100644 index 00000000000..b233e134a70 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/ChangesContent.tsx @@ -0,0 +1,135 @@ +import { useState } from "react"; +import { trpc } from "renderer/lib/trpc"; +import { useChangesStore } from "renderer/stores/changes"; +import { DiffToolbar } from "./components/DiffToolbar"; +import { DiffViewer } from "./components/DiffViewer"; +import { DiscardConfirmDialog } from "./components/DiscardConfirmDialog"; +import { EmptyState } from "./components/EmptyState"; +import { FileHeader } from "./components/FileHeader"; +import { useFileActions } from "./hooks/useFileActions"; + +export function ChangesContent() { + const [showDiscardConfirm, setShowDiscardConfirm] = useState(false); + + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + const worktreePath = activeWorkspace?.worktreePath; + + const { viewMode, setViewMode, baseBranch, getSelectedFile } = + useChangesStore(); + + const selectedFileState = getSelectedFile(worktreePath || ""); + const selectedFile = selectedFileState?.file ?? null; + const selectedCategory = selectedFileState?.category ?? "against-main"; + const selectedCommitHash = selectedFileState?.commitHash ?? null; + + const { data: branchData } = trpc.changes.getBranches.useQuery( + { worktreePath: worktreePath || "" }, + { enabled: !!worktreePath }, + ); + + const effectiveBaseBranch = baseBranch ?? branchData?.defaultBranch ?? "main"; + + const { + data: contents, + isLoading: isLoadingContents, + error: contentsError, + } = trpc.changes.getFileContents.useQuery( + { + worktreePath: worktreePath || "", + filePath: selectedFile?.path || "", + category: selectedCategory, + commitHash: selectedCommitHash || undefined, + defaultBranch: effectiveBaseBranch, + }, + { + enabled: !!worktreePath && !!selectedFile, + }, + ); + + const { stage, unstage, discard, deleteFile, isPending } = useFileActions({ + worktreePath, + filePath: selectedFile?.path, + }); + + const isUnstaged = selectedCategory === "unstaged"; + const isStaged = selectedCategory === "staged"; + + const handleDiscard = () => { + if (!worktreePath || !selectedFile) return; + setShowDiscardConfirm(true); + }; + + const confirmDiscard = () => { + if (!worktreePath || !selectedFile) return; + if (selectedFile.status === "untracked") { + deleteFile(); + } else { + discard(); + } + }; + + if (!worktreePath) { + return ( + + ); + } + + if (!selectedFile) { + return ( + + ); + } + + if (isLoadingContents) { + return ( +
+ Loading diff... +
+ ); + } + + if (contentsError || !contents) { + return ( + + ); + } + + const isUntracked = selectedFile.status === "untracked"; + + return ( + <> +
+ + +
+ +
+
+ + + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffToolbar/DiffToolbar.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffToolbar/DiffToolbar.tsx new file mode 100644 index 00000000000..cbd8af7e5d1 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffToolbar/DiffToolbar.tsx @@ -0,0 +1,96 @@ +import { Button } from "@superset/ui/button"; +import { ToggleGroup, ToggleGroupItem } from "@superset/ui/toggle-group"; +import { + HiMiniArrowsRightLeft, + HiMiniListBullet, + HiMiniMinus, + HiMiniPlus, + HiMiniTrash, +} from "react-icons/hi2"; +import type { ChangeCategory, DiffViewMode } from "shared/changes-types"; + +interface DiffToolbarProps { + viewMode: DiffViewMode; + onViewModeChange: (mode: DiffViewMode) => void; + category: ChangeCategory; + onStage?: () => void; + onUnstage?: () => void; + onDiscard?: () => void; + isActioning?: boolean; +} + +export function DiffToolbar({ + viewMode, + onViewModeChange, + category, + onStage, + onUnstage, + onDiscard, + isActioning = false, +}: DiffToolbarProps) { + const canStage = category === "unstaged"; + const canUnstage = category === "staged"; + const canDiscard = category === "unstaged"; + + return ( +
+ {/* View mode toggle */} + { + if (value) onViewModeChange(value as DiffViewMode); + }} + variant="outline" + size="sm" + > + + + Side by Side + + + + Inline + + + + {/* Actions */} +
+ {canStage && onStage && ( + + )} + {canUnstage && onUnstage && ( + + )} + {canDiscard && onDiscard && ( + + )} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffToolbar/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffToolbar/index.ts new file mode 100644 index 00000000000..4eb845a5c63 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffToolbar/index.ts @@ -0,0 +1 @@ +export { DiffToolbar } from "./DiffToolbar"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffViewer/DiffViewer.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffViewer/DiffViewer.tsx new file mode 100644 index 00000000000..cd595573c2b --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffViewer/DiffViewer.tsx @@ -0,0 +1,46 @@ +import { DiffEditor } from "@monaco-editor/react"; +import { SUPERSET_THEME } from "renderer/contexts/MonacoProvider"; +import type { DiffViewMode, FileContents } from "shared/changes-types"; + +interface DiffViewerProps { + contents: FileContents; + viewMode: DiffViewMode; +} + +export function DiffViewer({ contents, viewMode }: DiffViewerProps) { + // Monaco is preloaded and theme is registered by MonacoProvider + return ( +
+ + Loading editor... +
+ } + options={{ + renderSideBySide: viewMode === "side-by-side", + readOnly: true, + minimap: { enabled: false }, + scrollBeyondLastLine: false, + renderOverviewRuler: false, + wordWrap: "on", + diffWordWrap: "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, + }, + }} + /> + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffViewer/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffViewer/index.ts new file mode 100644 index 00000000000..e256ea2ce1b --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffViewer/index.ts @@ -0,0 +1 @@ +export { DiffViewer } from "./DiffViewer"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiscardConfirmDialog/DiscardConfirmDialog.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiscardConfirmDialog/DiscardConfirmDialog.tsx new file mode 100644 index 00000000000..ea8ca9f197c --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiscardConfirmDialog/DiscardConfirmDialog.tsx @@ -0,0 +1,73 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@superset/ui/alert-dialog"; + +interface DiscardConfirmDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + filePath: string; + isUntracked: boolean; + onConfirm: () => void; +} + +export function DiscardConfirmDialog({ + open, + onOpenChange, + filePath, + isUntracked, + onConfirm, +}: DiscardConfirmDialogProps) { + const handleConfirm = (e: React.MouseEvent) => { + e.preventDefault(); + onConfirm(); + onOpenChange(false); + }; + + return ( + + + + + {isUntracked ? "Delete File" : "Discard Changes"} + + + {isUntracked ? ( + <> + Are you sure you want to permanently delete "{filePath}"? + + This will permanently delete this file from disk. This action + cannot be undone. + + + ) : ( + <> + Are you sure you want to discard all changes to "{filePath}"? + + This will revert the file to its last committed state. All + uncommitted changes will be lost. This action cannot be + undone. + + + )} + + + + Cancel + + {isUntracked ? "Delete" : "Discard"} + + + + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiscardConfirmDialog/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiscardConfirmDialog/index.ts new file mode 100644 index 00000000000..76dd01a29b9 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiscardConfirmDialog/index.ts @@ -0,0 +1 @@ +export { DiscardConfirmDialog } from "./DiscardConfirmDialog"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/EmptyState/EmptyState.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/EmptyState/EmptyState.tsx new file mode 100644 index 00000000000..24adcd3eb20 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/EmptyState/EmptyState.tsx @@ -0,0 +1,18 @@ +import { HiOutlineDocumentMagnifyingGlass } from "react-icons/hi2"; + +interface EmptyStateProps { + title: string; + description?: string; +} + +export function EmptyState({ title, description }: EmptyStateProps) { + return ( +
+ +

{title}

+ {description && ( +

{description}

+ )} +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/EmptyState/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/EmptyState/index.ts new file mode 100644 index 00000000000..aac2d4d6760 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/EmptyState/index.ts @@ -0,0 +1 @@ +export { EmptyState } from "./EmptyState"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/FileHeader/FileHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/FileHeader/FileHeader.tsx new file mode 100644 index 00000000000..341d4dd21c1 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/FileHeader/FileHeader.tsx @@ -0,0 +1,89 @@ +import { cn } from "@superset/ui/utils"; +import type { ChangedFile, FileStatus } from "shared/changes-types"; + +interface FileHeaderProps { + file: ChangedFile; +} + +function getStatusColor(status: FileStatus): string { + switch (status) { + case "added": + return "text-green-500"; + case "modified": + return "text-yellow-500"; + case "deleted": + return "text-red-500"; + case "renamed": + case "copied": + return "text-blue-500"; + case "untracked": + return "text-muted-foreground"; + default: + return "text-muted-foreground"; + } +} + +function getStatusLabel(status: string): string { + switch (status) { + case "added": + return "Added"; + case "modified": + return "Modified"; + case "deleted": + return "Deleted"; + case "renamed": + return "Renamed"; + case "copied": + return "Copied"; + case "untracked": + return "Untracked"; + default: + return status; + } +} + +export function FileHeader({ file }: FileHeaderProps) { + const statusColor = getStatusColor(file.status); + const statusLabel = getStatusLabel(file.status); + const hasStats = file.additions > 0 || file.deletions > 0; + + return ( +
+ {/* File path */} +
+
+ {file.path} +
+ {file.oldPath && ( +
+ {file.status === "copied" ? "Copied from" : "Renamed from"}{" "} + {file.oldPath} +
+ )} +
+ + {/* Status badge */} + + {statusLabel} + + + {/* Stats */} + {hasStats && ( +
+ {file.additions > 0 && ( + +{file.additions} + )} + {file.deletions > 0 && ( + -{file.deletions} + )} +
+ )} +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/FileHeader/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/FileHeader/index.ts new file mode 100644 index 00000000000..fc50477c31d --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/FileHeader/index.ts @@ -0,0 +1 @@ +export { FileHeader } from "./FileHeader"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/hooks/useFileActions/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/hooks/useFileActions/index.ts new file mode 100644 index 00000000000..5d1af41492e --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/hooks/useFileActions/index.ts @@ -0,0 +1 @@ +export { useFileActions } from "./useFileActions"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/hooks/useFileActions/useFileActions.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/hooks/useFileActions/useFileActions.ts new file mode 100644 index 00000000000..4a2c2cc2594 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/hooks/useFileActions/useFileActions.ts @@ -0,0 +1,69 @@ +import { trpc } from "renderer/lib/trpc"; + +interface UseFileActionsParams { + worktreePath: string | undefined; + filePath: string | undefined; +} + +export function useFileActions({ + worktreePath, + filePath, +}: UseFileActionsParams) { + const utils = trpc.useUtils(); + + const stageFile = trpc.changes.stageFile.useMutation({ + onSuccess: () => utils.changes.getStatus.invalidate(), + }); + + const unstageFile = trpc.changes.unstageFile.useMutation({ + onSuccess: () => utils.changes.getStatus.invalidate(), + }); + + const discardChanges = trpc.changes.discardChanges.useMutation({ + onSuccess: () => { + utils.changes.getStatus.invalidate(); + utils.changes.getFileContents.invalidate(); + }, + }); + + const deleteUntracked = trpc.changes.deleteUntracked.useMutation({ + onSuccess: () => { + utils.changes.getStatus.invalidate(); + utils.changes.getFileContents.invalidate(); + }, + }); + + const isPending = + stageFile.isPending || + unstageFile.isPending || + discardChanges.isPending || + deleteUntracked.isPending; + + const stage = () => { + if (!worktreePath || !filePath) return; + stageFile.mutate({ worktreePath, filePath }); + }; + + const unstage = () => { + if (!worktreePath || !filePath) return; + unstageFile.mutate({ worktreePath, filePath }); + }; + + const discard = () => { + if (!worktreePath || !filePath) return; + discardChanges.mutate({ worktreePath, filePath }); + }; + + const deleteFile = () => { + if (!worktreePath || !filePath) return; + deleteUntracked.mutate({ worktreePath, filePath }); + }; + + return { + stage, + unstage, + discard, + deleteFile, + isPending, + }; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/index.ts new file mode 100644 index 00000000000..061e27721ec --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/index.ts @@ -0,0 +1 @@ +export { ChangesContent } from "./ChangesContent"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView.tsx deleted file mode 100644 index 178ceffedd4..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export function ChangesView() { - return ( -
- Coming soon... -
- ); -} 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 new file mode 100644 index 00000000000..6e280aa8841 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/ChangesView.tsx @@ -0,0 +1,222 @@ +import { ScrollArea } from "@superset/ui/scroll-area"; +import { useState } from "react"; +import { trpc } from "renderer/lib/trpc"; +import { useChangesStore } from "renderer/stores/changes"; +import type { ChangeCategory, ChangedFile } from "shared/changes-types"; +import { CategorySection } from "./components/CategorySection"; +import { ChangesHeader } from "./components/ChangesHeader"; +import { CommitItem } from "./components/CommitItem"; +import { FileList } from "./components/FileList"; + +export function ChangesView() { + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + const worktreePath = activeWorkspace?.worktreePath; + + const { baseBranch } = useChangesStore(); + const { data: branchData } = trpc.changes.getBranches.useQuery( + { worktreePath: worktreePath || "" }, + { enabled: !!worktreePath }, + ); + + const effectiveBaseBranch = baseBranch ?? branchData?.defaultBranch ?? "main"; + + const { + data: status, + isLoading, + isFetching, + refetch, + } = trpc.changes.getStatus.useQuery( + { worktreePath: worktreePath || "", defaultBranch: effectiveBaseBranch }, + { + enabled: !!worktreePath, + refetchInterval: 2500, + refetchOnWindowFocus: true, + }, + ); + + const { + expandedSections, + fileListViewMode, + selectFile, + getSelectedFile, + toggleSection, + setFileListViewMode, + } = useChangesStore(); + + const selectedFileState = getSelectedFile(worktreePath || ""); + const selectedFile = selectedFileState?.file ?? null; + const selectedCommitHash = selectedFileState?.commitHash ?? null; + + const [expandedCommits, setExpandedCommits] = useState>( + new Set(), + ); + + const commitFilesQueries = trpc.useQueries((t) => + Array.from(expandedCommits).map((hash) => + t.changes.getCommitFiles({ + worktreePath: worktreePath || "", + commitHash: hash, + }), + ), + ); + + const commitFilesMap = new Map(); + Array.from(expandedCommits).forEach((hash, index) => { + const query = commitFilesQueries[index]; + if (query?.data) { + commitFilesMap.set(hash, query.data); + } + }); + + const handleFileSelect = (file: ChangedFile, category: ChangeCategory) => { + if (!worktreePath) return; + selectFile(worktreePath, file, category, null); + }; + + const handleCommitFileSelect = (file: ChangedFile, commitHash: string) => { + if (!worktreePath) return; + selectFile(worktreePath, file, "committed", commitHash); + }; + + const handleCommitToggle = (hash: string) => { + setExpandedCommits((prev) => { + const next = new Set(prev); + if (next.has(hash)) { + next.delete(hash); + } else { + next.add(hash); + } + return next; + }); + }; + + if (!worktreePath) { + return ( +
+ No workspace selected +
+ ); + } + + if (isLoading) { + return ( +
+ Loading changes... +
+ ); + } + + if (!status) { + return ( +
+ Unable to load changes +
+ ); + } + + const hasChanges = + status.againstMain.length > 0 || + status.commits.length > 0 || + status.staged.length > 0 || + status.unstaged.length > 0 || + status.untracked.length > 0; + + const commitsWithFiles = status.commits.map((commit) => ({ + ...commit, + files: commitFilesMap.get(commit.hash) || [], + })); + + const unstagedFiles = [...status.unstaged, ...status.untracked]; + + return ( +
+ refetch()} + viewMode={fileListViewMode} + onViewModeChange={setFileListViewMode} + worktreePath={worktreePath} + /> + + {!hasChanges ? ( +
+ No changes detected +
+ ) : ( + + {/* Against Main */} + toggleSection("against-main")} + > + handleFileSelect(file, "against-main")} + /> + + + {/* Commits */} + toggleSection("committed")} + > + {commitsWithFiles.map((commit) => ( + handleCommitToggle(commit.hash)} + selectedFile={selectedFile} + selectedCommitHash={selectedCommitHash} + onFileSelect={handleCommitFileSelect} + viewMode={fileListViewMode} + /> + ))} + + + {/* Staged */} + toggleSection("staged")} + > + handleFileSelect(file, "staged")} + /> + + + {/* Unstaged */} + toggleSection("unstaged")} + > + handleFileSelect(file, "unstaged")} + /> + + + )} +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/BaseBranchSelector/BaseBranchSelector.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/BaseBranchSelector/BaseBranchSelector.tsx new file mode 100644 index 00000000000..789dac8d1ac --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/BaseBranchSelector/BaseBranchSelector.tsx @@ -0,0 +1,64 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@superset/ui/select"; +import { trpc } from "renderer/lib/trpc"; +import { useChangesStore } from "renderer/stores/changes"; + +interface BaseBranchSelectorProps { + worktreePath: string; +} + +export function BaseBranchSelector({ worktreePath }: BaseBranchSelectorProps) { + const { baseBranch, setBaseBranch } = useChangesStore(); + + const { data: branchData, isLoading } = trpc.changes.getBranches.useQuery( + { worktreePath }, + { enabled: !!worktreePath }, + ); + + if (isLoading || !branchData) { + return null; + } + + // Use the stored baseBranch or fall back to auto-detected default + const effectiveBranch = baseBranch ?? branchData.defaultBranch; + + // Combine remote branches for selection (these are the ones we can compare against) + const availableBranches = branchData.remote; + + const handleChange = (value: string) => { + // If selecting the auto-detected default, store null to indicate "use default" + if (value === branchData.defaultBranch && baseBranch === null) { + return; + } + setBaseBranch(value); + }; + + return ( +
+ vs + +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/BaseBranchSelector/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/BaseBranchSelector/index.ts new file mode 100644 index 00000000000..7f9bd68a01a --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/BaseBranchSelector/index.ts @@ -0,0 +1 @@ +export { BaseBranchSelector } from "./BaseBranchSelector"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/CategorySection/CategorySection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/CategorySection/CategorySection.tsx new file mode 100644 index 00000000000..1baf2610d16 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/CategorySection/CategorySection.tsx @@ -0,0 +1,60 @@ +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@superset/ui/collapsible"; +import { cn } from "@superset/ui/utils"; +import type { ReactNode } from "react"; +import { HiChevronDown, HiChevronRight } from "react-icons/hi2"; + +interface CategorySectionProps { + title: string; + count: number; + isExpanded: boolean; + onToggle: () => void; + children: ReactNode; + actions?: ReactNode; +} + +export function CategorySection({ + title, + count, + isExpanded, + onToggle, + children, + actions, +}: CategorySectionProps) { + if (count === 0) { + return null; + } + + return ( + + {/* Section header */} +
+ + {isExpanded ? ( + + ) : ( + + )} + {title} + ({count}) + + {actions &&
{actions}
} +
+ + {/* Section content */} + {children} +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/CategorySection/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/CategorySection/index.ts new file mode 100644 index 00000000000..0e8f5919d0d --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/CategorySection/index.ts @@ -0,0 +1 @@ +export { CategorySection } from "./CategorySection"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx new file mode 100644 index 00000000000..e684ff55f93 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx @@ -0,0 +1,63 @@ +import { Button } from "@superset/ui/button"; +import { HiArrowPath } from "react-icons/hi2"; +import type { ChangesViewMode } from "../../types"; +import { BaseBranchSelector } from "../BaseBranchSelector"; +import { ViewModeToggle } from "../ViewModeToggle"; + +interface ChangesHeaderProps { + branch: string; + ahead: number; + behind: number; + isRefreshing: boolean; + onRefresh: () => void; + viewMode: ChangesViewMode; + onViewModeChange: (mode: ChangesViewMode) => void; + worktreePath: string; +} + +export function ChangesHeader({ + branch, + ahead, + behind, + isRefreshing, + onRefresh, + viewMode, + onViewModeChange, + worktreePath, +}: ChangesHeaderProps) { + return ( +
+
+
+
{branch}
+
+ {(ahead > 0 || behind > 0) && ( + <> + {ahead > 0 && ( + {ahead} ahead + )} + {ahead > 0 && behind > 0 && /} + {behind > 0 && ( + {behind} behind + )} + + )} + +
+
+ +
+ +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/ChangesHeader/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/ChangesHeader/index.ts new file mode 100644 index 00000000000..2d44c6bf794 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/ChangesHeader/index.ts @@ -0,0 +1 @@ +export { ChangesHeader } from "./ChangesHeader"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/CommitItem/CommitItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/CommitItem/CommitItem.tsx new file mode 100644 index 00000000000..ac98b2df66f --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/CommitItem/CommitItem.tsx @@ -0,0 +1,92 @@ +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@superset/ui/collapsible"; +import { cn } from "@superset/ui/utils"; +import { HiChevronDown, HiChevronRight } from "react-icons/hi2"; +import type { ChangedFile, CommitInfo } from "shared/changes-types"; +import type { ChangesViewMode } from "../../types"; +import { FileList } from "../FileList"; + +interface CommitItemProps { + commit: CommitInfo; + isExpanded: boolean; + onToggle: () => void; + selectedFile: ChangedFile | null; + selectedCommitHash: string | null; + onFileSelect: (file: ChangedFile, commitHash: string) => void; + viewMode: ChangesViewMode; +} + +function formatRelativeDate(date: Date): string { + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMinutes = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMinutes / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffMinutes < 1) return "just now"; + if (diffMinutes < 60) return `${diffMinutes}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return date.toLocaleDateString(); +} + +export function CommitItem({ + commit, + isExpanded, + onToggle, + selectedFile, + selectedCommitHash, + onFileSelect, + viewMode, +}: CommitItemProps) { + const hasFiles = commit.files.length > 0; + + const handleFileSelect = (file: ChangedFile) => { + onFileSelect(file, commit.hash); + }; + + const isCommitSelected = selectedCommitHash === commit.hash; + + return ( + + + {isExpanded ? ( + + ) : ( + + )} + + + {commit.shortHash} + + + {commit.message} + + + {formatRelativeDate(commit.date)} + + + + {hasFiles && ( + + + + )} + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/CommitItem/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/CommitItem/index.ts new file mode 100644 index 00000000000..8f649572036 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/CommitItem/index.ts @@ -0,0 +1 @@ +export { CommitItem } from "./CommitItem"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/FileItem/FileItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/FileItem/FileItem.tsx new file mode 100644 index 00000000000..9b9d97fcde2 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/FileItem/FileItem.tsx @@ -0,0 +1,93 @@ +import { cn } from "@superset/ui/utils"; +import type { ChangedFile } from "shared/changes-types"; + +interface FileItemProps { + file: ChangedFile; + isSelected: boolean; + onClick: () => void; + showStats?: boolean; +} + +function getStatusColor(status: string): string { + switch (status) { + case "added": + return "text-green-500"; + case "modified": + return "text-yellow-500"; + case "deleted": + return "text-red-500"; + case "renamed": + return "text-blue-500"; + case "untracked": + return "text-muted-foreground"; + default: + return "text-muted-foreground"; + } +} + +function getStatusIndicator(status: string): string { + switch (status) { + case "added": + return "A"; + case "modified": + return "M"; + case "deleted": + return "D"; + case "renamed": + return "R"; + case "copied": + return "C"; + case "untracked": + return "?"; + default: + return ""; + } +} + +function getFileName(path: string): string { + return path.split("/").pop() || path; +} + +export function FileItem({ + file, + isSelected, + onClick, + showStats = true, +}: FileItemProps) { + const fileName = getFileName(file.path); + const statusColor = getStatusColor(file.status); + const statusIndicator = getStatusIndicator(file.status); + const hasStats = showStats && (file.additions > 0 || file.deletions > 0); + + return ( + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/FileItem/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/FileItem/index.ts new file mode 100644 index 00000000000..20531c3a8b2 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/FileItem/index.ts @@ -0,0 +1 @@ +export { FileItem } from "./FileItem"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/FileList/FileList.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/FileList/FileList.tsx new file mode 100644 index 00000000000..89f17350b5f --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/FileList/FileList.tsx @@ -0,0 +1,49 @@ +import type { ChangedFile } from "shared/changes-types"; +import type { ChangesViewMode } from "../../types"; +import { FileListGrouped } from "./FileListGrouped"; +import { FileListTree } from "./FileListTree"; + +interface FileListProps { + files: ChangedFile[]; + viewMode: ChangesViewMode; + selectedFile: ChangedFile | null; + selectedCommitHash: string | null; + onFileSelect: (file: ChangedFile) => void; + showStats?: boolean; +} + +export function FileList({ + files, + viewMode, + selectedFile, + selectedCommitHash, + onFileSelect, + showStats = true, +}: FileListProps) { + if (files.length === 0) { + return null; + } + + if (viewMode === "tree") { + return ( + + ); + } + + // Grouped mode - group files by folder + return ( + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/FileList/FileListGrouped.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/FileList/FileListGrouped.tsx new file mode 100644 index 00000000000..c6fbbe09ecc --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/FileList/FileListGrouped.tsx @@ -0,0 +1,131 @@ +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@superset/ui/collapsible"; +import { cn } from "@superset/ui/utils"; +import { useState } from "react"; +import { HiChevronDown, HiChevronRight } from "react-icons/hi2"; +import type { ChangedFile } from "shared/changes-types"; +import { FileItem } from "../FileItem"; + +interface FileListGroupedProps { + files: ChangedFile[]; + selectedFile: ChangedFile | null; + selectedCommitHash: string | null; + onFileSelect: (file: ChangedFile) => void; + showStats?: boolean; +} + +interface FolderGroup { + folderPath: string; + folderName: string; + files: ChangedFile[]; +} + +function groupFilesByFolder(files: ChangedFile[]): FolderGroup[] { + const folderMap = new Map(); + + for (const file of files) { + const pathParts = file.path.split("/"); + const folderPath = + pathParts.length > 1 ? pathParts.slice(0, -1).join("/") : ""; + + if (!folderMap.has(folderPath)) { + folderMap.set(folderPath, []); + } + folderMap.get(folderPath)?.push(file); + } + + return Array.from(folderMap.entries()) + .map(([folderPath, files]) => { + const pathParts = folderPath.split("/"); + const folderName = + folderPath === "" ? "" : pathParts[pathParts.length - 1]; + + return { + folderPath, + folderName, + files: files.sort((a, b) => { + const aName = a.path.split("/").pop() || ""; + const bName = b.path.split("/").pop() || ""; + return aName.localeCompare(bName); + }), + }; + }) + .sort((a, b) => a.folderPath.localeCompare(b.folderPath)); +} + +interface FolderGroupItemProps { + group: FolderGroup; + selectedFile: ChangedFile | null; + selectedCommitHash: string | null; + onFileSelect: (file: ChangedFile) => void; + showStats?: boolean; +} + +function FolderGroupItem({ + group, + selectedFile, + onFileSelect, + showStats, +}: FolderGroupItemProps) { + const [isExpanded, setIsExpanded] = useState(true); + const isRoot = group.folderPath === ""; + const displayName = isRoot ? "Root" : group.folderPath; + + return ( + + + {isExpanded ? ( + + ) : ( + + )} + {displayName} + {group.files.length} + + + {group.files.map((file) => ( + onFileSelect(file)} + showStats={showStats} + /> + ))} + + + ); +} + +export function FileListGrouped({ + files, + selectedFile, + selectedCommitHash, + onFileSelect, + showStats = true, +}: FileListGroupedProps) { + const groups = groupFilesByFolder(files); + + return ( +
+ {groups.map((group) => ( + + ))} +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/FileList/FileListTree.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/FileList/FileListTree.tsx new file mode 100644 index 00000000000..f4f6fffbded --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/FileList/FileListTree.tsx @@ -0,0 +1,233 @@ +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@superset/ui/collapsible"; +import { cn } from "@superset/ui/utils"; +import { useState } from "react"; +import { HiChevronDown, HiChevronRight } from "react-icons/hi2"; +import type { ChangedFile } from "shared/changes-types"; + +interface FileListTreeProps { + files: ChangedFile[]; + selectedFile: ChangedFile | null; + selectedCommitHash: string | null; + onFileSelect: (file: ChangedFile) => void; + showStats?: boolean; +} + +interface FileTreeNode { + id: string; + name: string; + type: "file" | "folder"; + path: string; + file?: ChangedFile; + children?: FileTreeNode[]; +} + +function getStatusColor(status: string): string { + switch (status) { + case "added": + return "text-green-500"; + case "modified": + return "text-yellow-500"; + case "deleted": + return "text-red-500"; + case "renamed": + return "text-blue-500"; + case "untracked": + return "text-muted-foreground"; + default: + return "text-muted-foreground"; + } +} + +function getStatusIndicator(status: string): string { + switch (status) { + case "added": + return "A"; + case "modified": + return "M"; + case "deleted": + return "D"; + case "renamed": + return "R"; + case "copied": + return "C"; + case "untracked": + return "?"; + default: + return ""; + } +} + +function buildFileTree(files: ChangedFile[]): FileTreeNode[] { + type TreeNodeInternal = Omit & { + children?: Record; + }; + + const root: Record = {}; + + for (const file of files) { + const parts = file.path.split("/"); + let current = root; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const isLast = i === parts.length - 1; + const pathSoFar = parts.slice(0, i + 1).join("/"); + + if (!current[part]) { + current[part] = { + id: pathSoFar, + name: part, + type: isLast ? "file" : "folder", + path: pathSoFar, + file: isLast ? file : undefined, + children: isLast ? undefined : {}, + }; + } + + if (!isLast && current[part].children) { + current = current[part].children; + } + } + } + + function convertToArray( + nodes: Record, + ): FileTreeNode[] { + return Object.values(nodes) + .map((node) => ({ + ...node, + children: node.children ? convertToArray(node.children) : undefined, + })) + .sort((a, b) => { + if (a.type !== b.type) { + return a.type === "folder" ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + } + + return convertToArray(root); +} + +interface TreeNodeComponentProps { + node: FileTreeNode; + level?: number; + selectedPath: string | null; + selectedCommitHash: string | null; + onFileSelect: (file: ChangedFile) => void; + showStats?: boolean; +} + +function TreeNodeComponent({ + node, + level = 0, + selectedPath, + selectedCommitHash, + onFileSelect, + showStats, +}: TreeNodeComponentProps) { + const [isExpanded, setIsExpanded] = useState(true); + const hasChildren = node.children && node.children.length > 0; + const isFile = node.type === "file"; + const isSelected = selectedPath === node.path && !selectedCommitHash; + + const statusColor = node.file?.status ? getStatusColor(node.file.status) : ""; + const statusIndicator = node.file?.status + ? getStatusIndicator(node.file.status) + : ""; + + if (hasChildren) { + return ( + + + {isExpanded ? ( + + ) : ( + + )} + + {node.name} + + + + {node.children?.map((child) => ( + + ))} + + + ); + } + + return ( + + ); +} + +export function FileListTree({ + files, + selectedFile, + selectedCommitHash, + onFileSelect, + showStats = true, +}: FileListTreeProps) { + const tree = buildFileTree(files); + + return ( +
+ {tree.map((node) => ( + + ))} +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/FileList/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/FileList/index.ts new file mode 100644 index 00000000000..517405d0959 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/FileList/index.ts @@ -0,0 +1,3 @@ +export { FileList } from "./FileList"; +export { FileListGrouped } from "./FileListGrouped"; +export { FileListTree } from "./FileListTree"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/ViewModeToggle/ViewModeToggle.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/ViewModeToggle/ViewModeToggle.tsx new file mode 100644 index 00000000000..0f8c2ddf7be --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/ViewModeToggle/ViewModeToggle.tsx @@ -0,0 +1,32 @@ +import { ToggleGroup, ToggleGroupItem } from "@superset/ui/toggle-group"; +import { HiFolder, HiListBullet } from "react-icons/hi2"; +import type { ChangesViewMode } from "../../types"; + +interface ViewModeToggleProps { + viewMode: ChangesViewMode; + onViewModeChange: (mode: ChangesViewMode) => void; +} + +export function ViewModeToggle({ + viewMode, + onViewModeChange, +}: ViewModeToggleProps) { + return ( + { + if (value) onViewModeChange(value as ChangesViewMode); + }} + variant="outline" + size="sm" + > + + + + + + + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/ViewModeToggle/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/ViewModeToggle/index.ts new file mode 100644 index 00000000000..5e69ac17ef8 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/ViewModeToggle/index.ts @@ -0,0 +1 @@ +export { ViewModeToggle } from "./ViewModeToggle"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/index.ts new file mode 100644 index 00000000000..685f820396a --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/index.ts @@ -0,0 +1 @@ +export { ChangesView } from "./ChangesView"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/types.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/types.ts new file mode 100644 index 00000000000..c1c8978679c --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/types.ts @@ -0,0 +1,14 @@ +export type ChangesViewMode = "grouped" | "tree"; + +export interface FileTreeNode { + id: string; + name: string; + type: "file" | "folder"; + path: string; + status?: string; + additions?: number; + deletions?: number; + oldPath?: string; + category?: string; + children?: FileTreeNode[]; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/GitFileTree/FileTreeView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/GitFileTree/FileTreeView.tsx new file mode 100644 index 00000000000..4fd624e789a --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/GitFileTree/FileTreeView.tsx @@ -0,0 +1,104 @@ +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@superset/ui/collapsible"; +import { cn } from "@superset/ui/utils"; +import { useState } from "react"; +import { HiChevronDown, HiChevronRight } from "react-icons/hi2"; +import type { FileTreeNode } from "./types"; +import { getStatusColor, getStatusIndicator } from "./utils"; + +interface FileTreeViewProps { + tree: FileTreeNode[]; + onFileSelect?: (file: FileTreeNode) => void; +} + +interface TreeNodeProps { + node: FileTreeNode; + level?: number; + onFileSelect?: (file: FileTreeNode) => void; +} + +function TreeNode({ node, level = 0, onFileSelect }: TreeNodeProps) { + const [isExpanded, setIsExpanded] = useState(true); + const hasChildren = node.children && node.children.length > 0; + const isFile = node.type === "file"; + + const statusColor = node.status ? getStatusColor(node.status) : ""; + const statusIndicator = node.status ? getStatusIndicator(node.status) : ""; + + if (hasChildren) { + return ( + + + {isExpanded ? ( + + ) : ( + + )} + + {node.name} + + + + {node.children?.map((child) => ( + + ))} + + + ); + } + + return ( + + ); +} + +export function FileTreeView({ tree, onFileSelect }: FileTreeViewProps) { + return ( +
+ {tree.length === 0 ? ( +
+ No files to display +
+ ) : ( + tree.map((node) => ( + + )) + )} +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/GitFileTree/GitFileTree.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/GitFileTree/GitFileTree.tsx new file mode 100644 index 00000000000..aeac499f627 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/GitFileTree/GitFileTree.tsx @@ -0,0 +1,77 @@ +import { ToggleGroup, ToggleGroupItem } from "@superset/ui/toggle-group"; +import { useMemo, useState } from "react"; +import { HiFolder, HiOutlineFolder } from "react-icons/hi2"; +import { FileTreeView } from "./FileTreeView"; +import { GroupedView } from "./GroupedView"; +import type { GitFile, ViewMode } from "./types"; +import { buildFileTree, groupFilesByFolder } from "./utils"; + +interface GitFileTreeProps { + files?: GitFile[]; + onFileSelect?: (file: GitFile) => void; + defaultMode?: ViewMode; +} + +export function GitFileTree({ + files = [], + onFileSelect, + defaultMode = "tree", +}: GitFileTreeProps) { + const [viewMode, setViewMode] = useState(defaultMode); + + const fileTree = useMemo(() => buildFileTree(files), [files]); + const groupedFiles = useMemo(() => groupFilesByFolder(files), [files]); + + const handleFileSelect = ( + file: GitFile | { path: string; status?: string }, + ) => { + if (onFileSelect && "id" in file) { + onFileSelect(file as GitFile); + } + }; + + return ( +
+ {/* Mode Toggle */} +
+ { + if (value) setViewMode(value as ViewMode); + }} + variant="outline" + size="sm" + > + + + Tree + + + + Grouped + + +
+ + {/* Content */} +
+ {viewMode === "tree" ? ( + { + if (node.type === "file") { + const file = files.find((f) => f.path === node.path); + if (file) { + handleFileSelect(file); + } + } + }} + /> + ) : ( + + )} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/GitFileTree/GroupedView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/GitFileTree/GroupedView.tsx new file mode 100644 index 00000000000..fde08a3e04c --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/GitFileTree/GroupedView.tsx @@ -0,0 +1,103 @@ +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@superset/ui/collapsible"; +import { cn } from "@superset/ui/utils"; +import { useState } from "react"; +import { HiChevronDown, HiChevronRight } from "react-icons/hi2"; +import type { FolderGroup, GitFile } from "./types"; +import { getStatusColor, getStatusIndicator } from "./utils"; + +interface GroupedViewProps { + groups: FolderGroup[]; + onFileSelect?: (file: GitFile) => void; +} + +interface FolderGroupItemProps { + group: FolderGroup; + onFileSelect?: (file: GitFile) => void; +} + +function FolderGroupItem({ group, onFileSelect }: FolderGroupItemProps) { + const [isExpanded, setIsExpanded] = useState(true); + + return ( + + + {isExpanded ? ( + + ) : ( + + )} + {group.folderName} + + {group.files.length} + + + + {group.files.map((file) => { + const statusColor = getStatusColor(file.status); + const statusIndicator = getStatusIndicator(file.status); + + return ( + + ); + })} + + + ); +} + +export function GroupedView({ groups, onFileSelect }: GroupedViewProps) { + return ( +
+ {groups.length === 0 ? ( +
+ No files to display +
+ ) : ( + groups.map((group) => ( + + )) + )} +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/GitFileTree/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/GitFileTree/index.ts new file mode 100644 index 00000000000..73f244c3a78 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/GitFileTree/index.ts @@ -0,0 +1,9 @@ +export { GitFileTree } from "./GitFileTree"; +export type { + FileTreeNode, + FolderGroup, + GitFile, + GitFileStatus, + ViewMode, +} from "./types"; +export { buildFileTree, groupFilesByFolder } from "./utils"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/GitFileTree/types.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/GitFileTree/types.ts new file mode 100644 index 00000000000..4e802025a1d --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/GitFileTree/types.ts @@ -0,0 +1,34 @@ +export type GitFileStatus = + | "added" + | "modified" + | "deleted" + | "renamed" + | "untracked"; + +export interface GitFile { + id: string; + path: string; + name: string; + status: GitFileStatus; + oldPath?: string; // For renamed files + staged: boolean; +} + +export type ViewMode = "tree" | "grouped"; + +export interface FileTreeNode { + id: string; + name: string; + type: "file" | "folder"; + path: string; + status?: GitFileStatus; + staged?: boolean; + oldPath?: string; + children?: FileTreeNode[]; +} + +export interface FolderGroup { + folderPath: string; + folderName: string; + files: GitFile[]; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/GitFileTree/utils.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/GitFileTree/utils.ts new file mode 100644 index 00000000000..a417975e8cd --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/GitFileTree/utils.ts @@ -0,0 +1,141 @@ +import type { FileTreeNode, FolderGroup, GitFile } from "./types"; + +/** + * Internal type for building the tree structure + */ +type TreeNodeInternal = Omit & { + children?: Record; +}; + +/** + * Converts a flat list of git files into a tree structure + */ +export function buildFileTree(files: GitFile[]): FileTreeNode[] { + const root: Record = {}; + + for (const file of files) { + const parts = file.path.split("/"); + let current = root; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const isLast = i === parts.length - 1; + const pathSoFar = parts.slice(0, i + 1).join("/"); + + if (!current[part]) { + current[part] = { + id: pathSoFar, + name: part, + type: isLast ? "file" : "folder", + path: pathSoFar, + children: isLast ? undefined : {}, + }; + } + + if (isLast) { + current[part].status = file.status; + current[part].staged = file.staged; + current[part].oldPath = file.oldPath; + } else if (current[part].children) { + current = current[part].children; + } + } + } + + function convertToArray( + nodes: Record, + ): FileTreeNode[] { + return Object.values(nodes) + .map((node) => ({ + ...node, + children: node.children ? convertToArray(node.children) : undefined, + })) + .sort((a, b) => { + // Folders first, then files + if (a.type !== b.type) { + return a.type === "folder" ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + } + + return convertToArray(root); +} + +/** + * Groups files by their folder path + */ +export function groupFilesByFolder(files: GitFile[]): FolderGroup[] { + const folderMap = new Map(); + + for (const file of files) { + const pathParts = file.path.split("/"); + const folderPath = + pathParts.length > 1 ? pathParts.slice(0, -1).join("/") : "root"; + + if (!folderMap.has(folderPath)) { + folderMap.set(folderPath, []); + } + folderMap.get(folderPath)?.push(file); + } + + return Array.from(folderMap.entries()) + .map(([folderPath, files]) => { + const pathParts = folderPath.split("/"); + const folderName = + pathParts.length > 0 && pathParts[pathParts.length - 1] !== "" + ? pathParts[pathParts.length - 1] + : "root"; + + return { + folderPath, + folderName, + files: files.sort((a, b) => a.name.localeCompare(b.name)), + }; + }) + .sort((a, b) => { + if (a.folderPath === "root") return -1; + if (b.folderPath === "root") return 1; + return a.folderPath.localeCompare(b.folderPath); + }); +} + +/** + * Gets the status color for a git file status + */ +export function getStatusColor(status: string): string { + switch (status) { + case "added": + return "text-green-500"; + case "modified": + return "text-yellow-500"; + case "deleted": + return "text-red-500"; + case "renamed": + return "text-blue-500"; + case "untracked": + return "text-gray-500"; + default: + return "text-muted-foreground"; + } +} + +/** + * Gets the status icon/indicator for a git file status + */ +export function getStatusIndicator(status: string): string { + switch (status) { + case "added": + return "+"; + case "modified": + return "M"; + case "deleted": + return "D"; + case "renamed": + return "R"; + case "untracked": + return "?"; + default: + return ""; + } +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ModeCarousel/ModeContent.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ModeCarousel/ModeContent.tsx index 70f704580e9..6d467b5cac5 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ModeCarousel/ModeContent.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ModeCarousel/ModeContent.tsx @@ -1,5 +1,4 @@ import type { ReactNode } from "react"; -import { ModeHeader } from "./ModeHeader"; import type { SidebarMode } from "./types"; interface ModeContentProps { @@ -8,7 +7,7 @@ interface ModeContentProps { children: ReactNode; } -export function ModeContent({ mode, children }: ModeContentProps) { +export function ModeContent({ children }: ModeContentProps) { return (
-
{children}
); diff --git a/apps/desktop/src/renderer/stores/changes/index.ts b/apps/desktop/src/renderer/stores/changes/index.ts new file mode 100644 index 00000000000..79247a86a96 --- /dev/null +++ b/apps/desktop/src/renderer/stores/changes/index.ts @@ -0,0 +1 @@ +export { useChangesStore } from "./store"; diff --git a/apps/desktop/src/renderer/stores/changes/store.ts b/apps/desktop/src/renderer/stores/changes/store.ts new file mode 100644 index 00000000000..c149e6facb5 --- /dev/null +++ b/apps/desktop/src/renderer/stores/changes/store.ts @@ -0,0 +1,134 @@ +import type { + ChangeCategory, + ChangedFile, + DiffViewMode, +} from "shared/changes-types"; +import { create } from "zustand"; +import { devtools, persist } from "zustand/middleware"; + +type FileListViewMode = "grouped" | "tree"; + +interface SelectedFileState { + file: ChangedFile; + category: ChangeCategory; + commitHash: string | null; +} + +interface ChangesState { + selectedFiles: Record; // worktreePath → selection + viewMode: DiffViewMode; + fileListViewMode: FileListViewMode; + expandedSections: Record; + baseBranch: string | null; + + // Actions + selectFile: ( + worktreePath: string, + file: ChangedFile | null, + category?: ChangeCategory, + commitHash?: string | null, + ) => void; + getSelectedFile: (worktreePath: string) => SelectedFileState | null; + setViewMode: (mode: DiffViewMode) => void; + setFileListViewMode: (mode: FileListViewMode) => void; + toggleSection: (section: ChangeCategory) => void; + setSectionExpanded: (section: ChangeCategory, expanded: boolean) => void; + setBaseBranch: (branch: string | null) => void; + reset: (worktreePath: string) => void; +} + +const initialState = { + selectedFiles: {} as Record, + viewMode: "side-by-side" as DiffViewMode, + fileListViewMode: "grouped" as FileListViewMode, + expandedSections: { + "against-main": true, + committed: true, + staged: true, + unstaged: true, + }, + baseBranch: null, +}; + +export const useChangesStore = create()( + devtools( + persist( + (set, get) => ({ + ...initialState, + + selectFile: (worktreePath, file, category, commitHash) => { + const { selectedFiles } = get(); + set({ + selectedFiles: { + ...selectedFiles, + [worktreePath]: file + ? { + file, + category: category ?? "against-main", + commitHash: commitHash ?? null, + } + : null, + }, + }); + }, + + getSelectedFile: (worktreePath) => { + return get().selectedFiles[worktreePath] ?? null; + }, + + setViewMode: (mode) => { + set({ viewMode: mode }); + }, + + setFileListViewMode: (mode) => { + set({ fileListViewMode: mode }); + }, + + toggleSection: (section) => { + const { expandedSections } = get(); + set({ + expandedSections: { + ...expandedSections, + [section]: !expandedSections[section], + }, + }); + }, + + setSectionExpanded: (section, expanded) => { + const { expandedSections } = get(); + set({ + expandedSections: { + ...expandedSections, + [section]: expanded, + }, + }); + }, + + setBaseBranch: (branch) => { + set({ baseBranch: branch }); + }, + + reset: (worktreePath) => { + const { selectedFiles } = get(); + set({ + selectedFiles: { + ...selectedFiles, + [worktreePath]: null, + }, + }); + }, + }), + { + name: "changes-store", + partialize: (state) => ({ + selectedFiles: state.selectedFiles, + viewMode: state.viewMode, + fileListViewMode: state.fileListViewMode, + expandedSections: state.expandedSections, + baseBranch: state.baseBranch, + }), + }, + ), + { name: "ChangesStore" }, + ), +); diff --git a/apps/desktop/src/renderer/stores/theme/index.ts b/apps/desktop/src/renderer/stores/theme/index.ts index c0f23487b0a..dd4041102ad 100644 --- a/apps/desktop/src/renderer/stores/theme/index.ts +++ b/apps/desktop/src/renderer/stores/theme/index.ts @@ -1,4 +1,5 @@ export { + useMonacoTheme, useSetTheme, useTerminalTheme, useTheme, diff --git a/apps/desktop/src/renderer/stores/theme/store.ts b/apps/desktop/src/renderer/stores/theme/store.ts index 762b78e2417..ba7c390f3ac 100644 --- a/apps/desktop/src/renderer/stores/theme/store.ts +++ b/apps/desktop/src/renderer/stores/theme/store.ts @@ -8,7 +8,13 @@ import { import { create } from "zustand"; import { devtools, persist } from "zustand/middleware"; import { trpcThemeStorage } from "../../lib/trpc-storage"; -import { applyUIColors, toXtermTheme, updateThemeClass } from "./utils"; +import { + applyUIColors, + type MonacoTheme, + toMonacoTheme, + toXtermTheme, + updateThemeClass, +} from "./utils"; interface ThemeState { /** Current active theme ID */ @@ -23,6 +29,9 @@ interface ThemeState { /** Terminal theme in xterm.js format (derived from activeTheme) */ terminalTheme: ITheme | null; + /** Monaco editor theme (derived from activeTheme) */ + monacoTheme: MonacoTheme | null; + /** Set the active theme by ID */ setTheme: (themeId: string) => void; @@ -67,18 +76,23 @@ function syncThemeToLocalStorage(theme: Theme): void { /** * Apply a theme to the UI and terminal */ -function applyTheme(theme: Theme): ITheme { +function applyTheme(theme: Theme): { + terminalTheme: ITheme; + monacoTheme: MonacoTheme; +} { // Apply UI colors to CSS variables applyUIColors(theme.ui); // Update dark/light class updateThemeClass(theme.type); - // Sync theme to localStorage for instant flash-free loading syncThemeToLocalStorage(theme); - // Convert terminal colors to xterm format - return toXtermTheme(theme.terminal); + // Convert to editor-specific formats + return { + terminalTheme: toXtermTheme(theme.terminal), + monacoTheme: toMonacoTheme(theme), + }; } export const useThemeStore = create()( @@ -89,6 +103,7 @@ export const useThemeStore = create()( customThemes: [], activeTheme: null, terminalTheme: null, + monacoTheme: null, setTheme: (themeId: string) => { const state = get(); @@ -99,12 +114,13 @@ export const useThemeStore = create()( return; } - const terminalTheme = applyTheme(theme); + const { terminalTheme, monacoTheme } = applyTheme(theme); set({ activeThemeId: themeId, activeTheme: theme, terminalTheme, + monacoTheme, }); }, @@ -146,13 +162,13 @@ export const useThemeStore = create()( const theme = findTheme(state.activeThemeId, state.customThemes); if (theme) { - const terminalTheme = applyTheme(theme); + const { terminalTheme, monacoTheme } = applyTheme(theme); set({ activeTheme: theme, terminalTheme, + monacoTheme, }); } else { - // Fallback to default theme if saved theme not found state.setTheme(DEFAULT_THEME_ID); } }, @@ -165,12 +181,8 @@ export const useThemeStore = create()( customThemes: state.customThemes, }), onRehydrateStorage: () => (state) => { - // Initialize theme after hydration if (state) { - // Use setTimeout to ensure DOM is ready - setTimeout(() => { - state.initializeTheme(); - }, 0); + state.initializeTheme(); } }, }, @@ -183,5 +195,6 @@ export const useThemeStore = create()( export const useTheme = () => useThemeStore((state) => state.activeTheme); export const useTerminalTheme = () => useThemeStore((state) => state.terminalTheme); +export const useMonacoTheme = () => useThemeStore((state) => state.monacoTheme); export const useSetTheme = () => useThemeStore((state) => state.setTheme); export const useThemeId = () => useThemeStore((state) => state.activeThemeId); diff --git a/apps/desktop/src/renderer/stores/theme/utils/index.ts b/apps/desktop/src/renderer/stores/theme/utils/index.ts index 430703a3ba8..2fb7b4213fd 100644 --- a/apps/desktop/src/renderer/stores/theme/utils/index.ts +++ b/apps/desktop/src/renderer/stores/theme/utils/index.ts @@ -3,4 +3,6 @@ export { clearThemeVariables, updateThemeClass, } from "./css-variables"; +export type { MonacoTheme } from "./monaco-theme"; +export { toMonacoTheme } from "./monaco-theme"; export { toXtermTheme } from "./terminal-theme"; diff --git a/apps/desktop/src/renderer/stores/theme/utils/monaco-theme.ts b/apps/desktop/src/renderer/stores/theme/utils/monaco-theme.ts new file mode 100644 index 00000000000..7629275ba2b --- /dev/null +++ b/apps/desktop/src/renderer/stores/theme/utils/monaco-theme.ts @@ -0,0 +1,142 @@ +import type { editor } from "monaco-editor"; +import type { TerminalColors, Theme } from "shared/themes/types"; +import { stripHash, toHexAuto, withAlpha } from "shared/themes/utils"; + +export interface MonacoTheme { + base: "vs" | "vs-dark" | "hc-black"; + inherit: boolean; + rules: editor.ITokenThemeRule[]; + colors: editor.IColors; +} + +function tokenColor(color: string): string { + return stripHash(toHexAuto(color)); +} + +function createTokenRules(colors: TerminalColors): editor.ITokenThemeRule[] { + const c = tokenColor; + return [ + { token: "comment", foreground: c(colors.brightBlack) }, + { token: "comment.line", foreground: c(colors.brightBlack) }, + { token: "comment.block", foreground: c(colors.brightBlack) }, + + { token: "string", foreground: c(colors.green) }, + { token: "string.quoted", foreground: c(colors.green) }, + { token: "string.template", foreground: c(colors.green) }, + + { token: "keyword", foreground: c(colors.magenta) }, + { token: "keyword.control", foreground: c(colors.magenta) }, + { token: "keyword.operator", foreground: c(colors.red) }, + { token: "storage", foreground: c(colors.magenta) }, + { token: "storage.type", foreground: c(colors.cyan) }, + + { token: "number", foreground: c(colors.yellow) }, + { token: "constant.numeric", foreground: c(colors.yellow) }, + { token: "constant", foreground: c(colors.yellow) }, + { token: "constant.language", foreground: c(colors.yellow) }, + { token: "constant.character", foreground: c(colors.yellow) }, + + { token: "variable", foreground: c(colors.foreground) }, + { token: "variable.parameter", foreground: c(colors.foreground) }, + { token: "variable.other", foreground: c(colors.foreground) }, + + { token: "entity.name.function", foreground: c(colors.blue) }, + { token: "support.function", foreground: c(colors.blue) }, + { token: "meta.function-call", foreground: c(colors.blue) }, + + { token: "entity.name.type", foreground: c(colors.cyan) }, + { token: "entity.name.class", foreground: c(colors.cyan) }, + { token: "support.type", foreground: c(colors.cyan) }, + { token: "support.class", foreground: c(colors.cyan) }, + + { token: "entity.name.tag", foreground: c(colors.red) }, + { token: "tag", foreground: c(colors.red) }, + { token: "meta.tag", foreground: c(colors.red) }, + + { token: "entity.other.attribute-name", foreground: c(colors.yellow) }, + { token: "attribute.name", foreground: c(colors.yellow) }, + + { token: "keyword.operator", foreground: c(colors.red) }, + { token: "punctuation", foreground: c(colors.foreground) }, + + { token: "type", foreground: c(colors.cyan) }, + { token: "type.identifier", foreground: c(colors.cyan) }, + { token: "identifier", foreground: c(colors.foreground) }, + { token: "delimiter", foreground: c(colors.foreground) }, + + { token: "string.key.json", foreground: c(colors.red) }, + { token: "string.value.json", foreground: c(colors.green) }, + + { token: "regexp", foreground: c(colors.cyan) }, + + { token: "markup.heading", foreground: c(colors.red), fontStyle: "bold" }, + { token: "markup.bold", foreground: c(colors.yellow), fontStyle: "bold" }, + { + token: "markup.italic", + foreground: c(colors.magenta), + fontStyle: "italic", + }, + { token: "markup.inline.raw", foreground: c(colors.green) }, + ]; +} + +function createEditorColors(theme: Theme): editor.IColors { + const { terminal, ui } = theme; + const hex = toHexAuto; + const alpha = withAlpha; + + const selectionBg = terminal.selectionBackground + ? hex(terminal.selectionBackground) + : alpha(terminal.foreground, 0.2); + + return { + "editor.background": hex(terminal.background), + "editor.foreground": hex(terminal.foreground), + "editor.lineHighlightBackground": hex(ui.accent), + "editor.lineHighlightBorder": "#00000000", + "editor.selectionBackground": selectionBg, + "editor.selectionHighlightBackground": alpha(terminal.blue, 0.2), + "editor.inactiveSelectionBackground": alpha(terminal.foreground, 0.1), + "editor.findMatchBackground": alpha(terminal.yellow, 0.27), + "editor.findMatchHighlightBackground": alpha(terminal.yellow, 0.13), + + "editorLineNumber.foreground": hex(terminal.brightBlack), + "editorLineNumber.activeForeground": hex(terminal.foreground), + "editorGutter.background": hex(terminal.background), + "editorCursor.foreground": hex(terminal.cursor), + + "diffEditor.insertedTextBackground": alpha(terminal.green, 0.13), + "diffEditor.removedTextBackground": alpha(terminal.red, 0.13), + "diffEditor.insertedLineBackground": alpha(terminal.green, 0.08), + "diffEditor.removedLineBackground": alpha(terminal.red, 0.08), + "diffEditorGutter.insertedLineBackground": alpha(terminal.green, 0.2), + "diffEditorGutter.removedLineBackground": alpha(terminal.red, 0.2), + "diffEditor.diagonalFill": hex(ui.border), + + "scrollbar.shadow": "#00000000", + "scrollbarSlider.background": alpha(terminal.foreground, 0.13), + "scrollbarSlider.hoverBackground": alpha(terminal.foreground, 0.2), + "scrollbarSlider.activeBackground": alpha(terminal.foreground, 0.27), + + "editorWidget.background": hex(ui.popover), + "editorWidget.foreground": hex(ui.popoverForeground), + "editorWidget.border": hex(ui.border), + + "editorBracketMatch.background": alpha(terminal.cyan, 0.2), + "editorBracketMatch.border": hex(terminal.cyan), + + "editorIndentGuide.background": alpha(terminal.foreground, 0.08), + "editorIndentGuide.activeBackground": alpha(terminal.foreground, 0.2), + "editorWhitespace.foreground": alpha(terminal.foreground, 0.13), + "editorOverviewRuler.border": "#00000000", + }; +} + +export function toMonacoTheme(theme: Theme): MonacoTheme { + return { + base: theme.type === "dark" ? "vs-dark" : "vs", + inherit: true, + rules: createTokenRules(theme.terminal), + colors: createEditorColors(theme), + }; +} diff --git a/apps/desktop/src/shared/changes-types.ts b/apps/desktop/src/shared/changes-types.ts new file mode 100644 index 00000000000..29eba41a9c1 --- /dev/null +++ b/apps/desktop/src/shared/changes-types.ts @@ -0,0 +1,69 @@ +/** + * Types for the git changes/diff viewer feature + */ + +/** File status from git, matching short format codes */ +export type FileStatus = + | "added" + | "modified" + | "deleted" + | "renamed" + | "copied" + | "untracked"; + +/** Change categories for organizing the sidebar */ +export type ChangeCategory = + | "against-main" + | "committed" + | "staged" + | "unstaged"; + +/** A changed file entry */ +export interface ChangedFile { + path: string; // Relative path from repo root + oldPath?: string; // Original path for renames/copies + status: FileStatus; + additions: number; + deletions: number; +} + +/** A commit summary for the committed changes section */ +export interface CommitInfo { + hash: string; + shortHash: string; // Short hash (7 chars) + message: string; // Commit message (first line) + author: string; + date: Date; + files: ChangedFile[]; +} + +/** Full git changes status for a worktree */ +export interface GitChangesStatus { + branch: string; + defaultBranch: string; // Default branch (main/master) + againstMain: ChangedFile[]; // All files changed vs default branch + commits: CommitInfo[]; // Individual commits on branch (not on default) + staged: ChangedFile[]; + unstaged: ChangedFile[]; + untracked: ChangedFile[]; + ahead: number; // Commits ahead of default branch + behind: number; // Commits behind default branch +} + +/** Diff view mode toggle */ +export type DiffViewMode = "side-by-side" | "inline"; + +/** Input for getting file diff */ +export interface FileDiffInput { + worktreePath: string; + filePath: string; + category: ChangeCategory; + commitHash?: string; // For committed category: which commit to show +} + +/** File contents for Monaco diff editor */ +export interface FileContents { + original: string; // Original content (before changes) + modified: string; // Modified content (after changes) + language: string; // Detected language for syntax highlighting +} diff --git a/apps/desktop/src/shared/themes/utils.test.ts b/apps/desktop/src/shared/themes/utils.test.ts new file mode 100644 index 00000000000..175ca99642e --- /dev/null +++ b/apps/desktop/src/shared/themes/utils.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "bun:test"; +import { stripHash, toHex, toHex8, toHexAuto, withAlpha } from "./utils"; + +describe("toHex", () => { + it("converts hex to hex", () => { + expect(toHex("#ff0000")).toBe("#ff0000"); + expect(toHex("#FF0000")).toBe("#ff0000"); + }); + + it("converts rgb to hex", () => { + expect(toHex("rgb(255, 0, 0)")).toBe("#ff0000"); + expect(toHex("rgb(0, 255, 0)")).toBe("#00ff00"); + }); + + it("converts oklch to hex", () => { + expect(toHex("oklch(0.628 0.258 29.23)")).toBe("#ff0000"); + }); + + it("converts hsl to hex", () => { + expect(toHex("hsl(0, 100%, 50%)")).toBe("#ff0000"); + }); + + it("returns original for invalid colors", () => { + expect(toHex("not-a-color")).toBe("not-a-color"); + }); +}); + +describe("toHex8", () => { + it("converts to hex8 format with full alpha", () => { + expect(toHex8("#ff0000")).toBe("#ff0000ff"); + }); + + it("preserves alpha channel", () => { + expect(toHex8("rgba(255, 0, 0, 0.5)")).toBe("#ff000080"); + }); + + it("converts oklch with alpha", () => { + const result = toHex8("oklch(0.628 0.258 29.23 / 0.5)"); + expect(result).toMatch(/^#[0-9a-f]{8}$/); + }); +}); + +describe("toHexAuto", () => { + it("returns hex6 for opaque colors", () => { + expect(toHexAuto("#ff0000")).toBe("#ff0000"); + expect(toHexAuto("rgb(255, 0, 0)")).toBe("#ff0000"); + }); + + it("returns hex8 for transparent colors", () => { + expect(toHexAuto("rgba(255, 0, 0, 0.5)")).toBe("#ff000080"); + }); +}); + +describe("withAlpha", () => { + it("applies alpha to color", () => { + expect(withAlpha("#ff0000", 0.5)).toBe("#ff000080"); + expect(withAlpha("#ff0000", 1)).toBe("#ff0000ff"); + expect(withAlpha("#ff0000", 0)).toBe("#ff000000"); + }); + + it("works with non-hex colors", () => { + expect(withAlpha("rgb(255, 0, 0)", 0.5)).toBe("#ff000080"); + }); + + it("returns original for invalid colors", () => { + expect(withAlpha("invalid", 0.5)).toBe("invalid"); + }); +}); + +describe("stripHash", () => { + it("removes # prefix", () => { + expect(stripHash("#ff0000")).toBe("ff0000"); + expect(stripHash("#ff000080")).toBe("ff000080"); + }); + + it("handles strings without #", () => { + expect(stripHash("ff0000")).toBe("ff0000"); + }); +}); diff --git a/apps/desktop/src/shared/themes/utils.ts b/apps/desktop/src/shared/themes/utils.ts new file mode 100644 index 00000000000..c8b5ebe9250 --- /dev/null +++ b/apps/desktop/src/shared/themes/utils.ts @@ -0,0 +1,56 @@ +import { formatHex, formatHex8, parse } from "culori"; + +/** + * Convert any CSS color to hex format (#RRGGBB) + */ +export function toHex(color: string): string { + const parsed = parse(color); + if (!parsed) { + return color; + } + return formatHex(parsed); +} + +/** + * Convert any CSS color to hex8 format (#RRGGBBAA) + */ +export function toHex8(color: string): string { + const parsed = parse(color); + if (!parsed) { + return color; + } + return formatHex8(parsed); +} + +/** + * Convert color to hex, using hex8 only if alpha < 1 + */ +export function toHexAuto(color: string): string { + const parsed = parse(color); + if (!parsed) { + return color; + } + if (parsed.alpha !== undefined && parsed.alpha < 1) { + return formatHex8(parsed); + } + return formatHex(parsed); +} + +/** + * Apply alpha to a color and return as hex8 + */ +export function withAlpha(color: string, alpha: number): string { + const parsed = parse(color); + if (!parsed) { + return color; + } + parsed.alpha = alpha; + return formatHex8(parsed); +} + +/** + * Strip # prefix from hex color + */ +export function stripHash(hex: string): string { + return hex.replace("#", ""); +} diff --git a/bun.lock b/bun.lock index b3bc8cda16d..0a514644297 100644 --- a/bun.lock +++ b/bun.lock @@ -69,11 +69,12 @@ }, "apps/desktop": { "name": "@superset/desktop", - "version": "0.0.12", + "version": "0.0.13", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@monaco-editor/react": "^4.7.0", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", "@superset/ui": "workspace:*", @@ -92,6 +93,7 @@ "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", "clsx": "^2.1.1", + "culori": "^4.0.2", "date-fns": "^4.1.0", "dnd-core": "^16.0.1", "dotenv": "^17.2.3", @@ -106,6 +108,7 @@ "lodash": "^4.17.21", "lowdb": "^7.0.1", "lucide-react": "^0.555.0", + "monaco-editor": "^0.55.1", "nanoid": "^5.1.6", "node-pty": "1.1.0-beta30", "react": "^19.1.1", @@ -132,6 +135,7 @@ "devDependencies": { "@biomejs/biome": "^2.3.8", "@tailwindcss/vite": "^4.0.9", + "@types/culori": "^4.0.1", "@types/http-proxy": "^1.17.17", "@types/lodash": "^4.17.20", "@types/node": "^24.9.1", @@ -266,7 +270,9 @@ "name": "@superset/ui", "version": "0.0.0", "dependencies": { + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -588,6 +594,10 @@ "@mermaid-js/parser": ["@mermaid-js/parser@0.6.3", "", { "dependencies": { "langium": "3.3.1" } }, "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA=="], + "@monaco-editor/loader": ["@monaco-editor/loader@1.7.0", "", { "dependencies": { "state-local": "^1.0.6" } }, "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA=="], + + "@monaco-editor/react": ["@monaco-editor/react@4.7.0", "", { "dependencies": { "@monaco-editor/loader": "^1.5.0" }, "peerDependencies": { "monaco-editor": ">= 0.25.0 < 1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA=="], + "@monogrid/gainmap-js": ["@monogrid/gainmap-js@3.4.0", "", { "dependencies": { "promise-worker-transferable": "^1.0.4" }, "peerDependencies": { "three": ">= 0.159.0" } }, "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg=="], "@napi-rs/simple-git": ["@napi-rs/simple-git@0.1.22", "", { "optionalDependencies": { "@napi-rs/simple-git-android-arm-eabi": "0.1.22", "@napi-rs/simple-git-android-arm64": "0.1.22", "@napi-rs/simple-git-darwin-arm64": "0.1.22", "@napi-rs/simple-git-darwin-x64": "0.1.22", "@napi-rs/simple-git-freebsd-x64": "0.1.22", "@napi-rs/simple-git-linux-arm-gnueabihf": "0.1.22", "@napi-rs/simple-git-linux-arm64-gnu": "0.1.22", "@napi-rs/simple-git-linux-arm64-musl": "0.1.22", "@napi-rs/simple-git-linux-ppc64-gnu": "0.1.22", "@napi-rs/simple-git-linux-s390x-gnu": "0.1.22", "@napi-rs/simple-git-linux-x64-gnu": "0.1.22", "@napi-rs/simple-git-linux-x64-musl": "0.1.22", "@napi-rs/simple-git-win32-arm64-msvc": "0.1.22", "@napi-rs/simple-git-win32-ia32-msvc": "0.1.22", "@napi-rs/simple-git-win32-x64-msvc": "0.1.22" } }, "sha512-bMVoAKhpjTOPHkW/lprDPwv5aD4R4C3Irt8vn+SKA9wudLe9COLxOhurrKRsxmZccUbWXRF7vukNeGUAj5P8kA=="], @@ -658,10 +668,14 @@ "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="], + "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="], "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], + "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="], + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], @@ -924,6 +938,8 @@ "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], + "@types/culori": ["@types/culori@4.0.1", "", {}, "sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ=="], + "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], @@ -1340,6 +1356,8 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "culori": ["culori@4.0.2", "", {}, "sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw=="], + "cytoscape": ["cytoscape@3.33.1", "", {}, "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ=="], "cytoscape-cose-bilkent": ["cytoscape-cose-bilkent@4.1.0", "", { "dependencies": { "cose-base": "^1.0.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ=="], @@ -1462,7 +1480,7 @@ "dnd-multi-backend": ["dnd-multi-backend@8.1.2", "", { "peerDependencies": { "dnd-core": "^16.0.1" } }, "sha512-KPDVEsiM+6gNEegqZYTWJQgJxYV4vB91tUrvoKJjaS0wwWqT/jNU0P7xJAwCue/cbasJNvk2dFZH7tC+bjX1Rg=="], - "dompurify": ["dompurify@3.3.0", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ=="], + "dompurify": ["dompurify@3.2.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="], "dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="], @@ -1950,7 +1968,7 @@ "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], - "marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], + "marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="], "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], @@ -2126,6 +2144,8 @@ "mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="], + "monaco-editor": ["monaco-editor@0.55.1", "", { "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" } }, "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A=="], + "motion-dom": ["motion-dom@12.23.23", "", { "dependencies": { "motion-utils": "^12.23.6" } }, "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA=="], "motion-utils": ["motion-utils@12.23.6", "", {}, "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="], @@ -2586,6 +2606,8 @@ "stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="], + "state-local": ["state-local@1.0.7", "", {}, "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="], + "stats-gl": ["stats-gl@2.4.2", "", { "dependencies": { "@types/three": "*", "three": "^0.170.0" } }, "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ=="], "stats.js": ["stats.js@0.17.0", "", {}, "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw=="], @@ -3066,6 +3088,10 @@ "meow/type-fest": ["type-fest@3.13.1", "", {}, "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="], + "mermaid/dompurify": ["dompurify@3.3.0", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ=="], + + "mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], + "mermaid/uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], "micromark-extension-frontmatter/fault": ["fault@2.0.1", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ=="], diff --git a/packages/ui/package.json b/packages/ui/package.json index 8b8e9891724..3f90cd88511 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -26,6 +26,10 @@ "./kbd": "./src/components/kbd.tsx", "./sonner": "./src/components/sonner.tsx", "./command": "./src/components/command.tsx", + "./toggle": "./src/components/toggle.tsx", + "./toggle-group": "./src/components/toggle-group.tsx", + "./accordion": "./src/components/accordion.tsx", + "./collapsible": "./src/components/collapsible.tsx", "./utils": "./src/lib/utils.ts", "./hooks": "./src/hooks/index.ts", "./globals.css": "./src/globals.css" @@ -34,7 +38,9 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/ui/src/components/accordion.tsx b/packages/ui/src/components/accordion.tsx new file mode 100644 index 00000000000..cc0c74d2f24 --- /dev/null +++ b/packages/ui/src/components/accordion.tsx @@ -0,0 +1,64 @@ +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import type * as React from "react"; +import { HiChevronDown } from "react-icons/hi2"; + +import { cn } from "@/lib/utils"; + +function Accordion({ + ...props +}: React.ComponentProps) { + return ; +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + svg]:rotate-180", + className, + )} + {...props} + > + {children} + + + + ); +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
{children}
+
+ ); +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/packages/ui/src/components/collapsible.tsx b/packages/ui/src/components/collapsible.tsx new file mode 100644 index 00000000000..a3a6f38107d --- /dev/null +++ b/packages/ui/src/components/collapsible.tsx @@ -0,0 +1,26 @@ +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"; +import type * as React from "react"; + +function Collapsible({ + ...props +}: React.ComponentProps) { + return ; +} + +function CollapsibleTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CollapsibleContent({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/packages/ui/src/components/toggle-group.tsx b/packages/ui/src/components/toggle-group.tsx index 46121d93691..67aee02b063 100644 --- a/packages/ui/src/components/toggle-group.tsx +++ b/packages/ui/src/components/toggle-group.tsx @@ -3,8 +3,8 @@ import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"; import type { VariantProps } from "class-variance-authority"; import * as React from "react"; -import { toggleVariants } from "@/components/toggle"; -import { cn } from "@/lib/utils"; +import { cn } from "../lib/utils"; +import { toggleVariants } from "./toggle"; const ToggleGroupContext = React.createContext< VariantProps & { diff --git a/packages/ui/src/components/toggle.tsx b/packages/ui/src/components/toggle.tsx index ee49adb9120..77a4ecece47 100644 --- a/packages/ui/src/components/toggle.tsx +++ b/packages/ui/src/components/toggle.tsx @@ -2,7 +2,7 @@ import * as TogglePrimitive from "@radix-ui/react-toggle"; import { cva, type VariantProps } from "class-variance-authority"; import type * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "../lib/utils"; const toggleVariants = cva( "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",