diff --git a/app/git/GitCommandRunner.js b/app/git/GitCommandRunner.js new file mode 100644 index 0000000..2e1e687 --- /dev/null +++ b/app/git/GitCommandRunner.js @@ -0,0 +1,71 @@ +const { execFile } = require("child_process") + +const DEFAULT_TIMEOUT = 30000 +const DEFAULT_MAX_BUFFER = 1024 * 1024 * 24 + +function normalizeExecError(error, stderr) { + if (!error) return null + + if (error.code === "ENOENT") { + return "Git is not installed or is not available in PATH" + } + + return (stderr || error.message || String(error)).trim() +} + +function runGit(cwd, args, options = {}) { + return new Promise((resolve) => { + execFile( + "git", + args, + { + cwd, + timeout: options.timeout ?? DEFAULT_TIMEOUT, + maxBuffer: options.maxBuffer ?? DEFAULT_MAX_BUFFER, + windowsHide: true + }, + (error, stdout, stderr) => { + const result = { + success: !error, + stdout: stdout || "", + stderr: stderr || "", + error: normalizeExecError(error, stderr), + code: error?.code ?? 0 + } + + if (options.allowFailure) { + resolve(result) + return + } + + resolve(result) + } + ) + }) +} + +async function assertGitAvailable(cwd) { + const result = await runGit(cwd, ["--version"]) + + if (!result.success) { + throw new Error(result.error || "Git is not available") + } + + return result.stdout.trim() +} + +async function getRepositoryRoot(cwd) { + const result = await runGit(cwd, ["rev-parse", "--show-toplevel"]) + + if (!result.success) { + throw new Error("Current project is not a Git repository") + } + + return result.stdout.trim() +} + +module.exports = { + runGit, + assertGitAvailable, + getRepositoryRoot +} diff --git a/app/git/GitDiffService.js b/app/git/GitDiffService.js new file mode 100644 index 0000000..162ba3b --- /dev/null +++ b/app/git/GitDiffService.js @@ -0,0 +1,159 @@ +const fs = require("fs") +const path = require("path") +const { runGit } = require("./GitCommandRunner.js") + +function parseUnifiedDiff(diffText) { + if (!diffText || !diffText.trim()) { + return [] + } + + const files = [] + let currentFile = null + let oldLine = 0 + let newLine = 0 + + diffText.split(/\r?\n/).forEach((line) => { + if (line.startsWith("diff --git ")) { + currentFile = { + header: line, + oldPath: null, + newPath: null, + hunks: [], + lines: [] + } + files.push(currentFile) + return + } + + if (!currentFile) { + currentFile = { + header: "", + oldPath: null, + newPath: null, + hunks: [], + lines: [] + } + files.push(currentFile) + } + + if (line.startsWith("--- ")) { + currentFile.oldPath = line.slice(4) + currentFile.lines.push({ type: "meta", content: line, oldLine: null, newLine: null }) + return + } + + if (line.startsWith("+++ ")) { + currentFile.newPath = line.slice(4) + currentFile.lines.push({ type: "meta", content: line, oldLine: null, newLine: null }) + return + } + + if (line.startsWith("@@")) { + const match = line.match(/@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/) + oldLine = match ? Number(match[1]) : 0 + newLine = match ? Number(match[2]) : 0 + currentFile.hunks.push(line) + currentFile.lines.push({ type: "hunk", content: line, oldLine: null, newLine: null }) + return + } + + if (line.startsWith("+") && !line.startsWith("+++")) { + currentFile.lines.push({ type: "added", content: line, oldLine: null, newLine }) + newLine += 1 + return + } + + if (line.startsWith("-") && !line.startsWith("---")) { + currentFile.lines.push({ type: "removed", content: line, oldLine, newLine: null }) + oldLine += 1 + return + } + + if (line.startsWith("\\ No newline")) { + currentFile.lines.push({ type: "meta", content: line, oldLine: null, newLine: null }) + return + } + + currentFile.lines.push({ + type: line.startsWith("index ") || line.startsWith("new file ") || line.startsWith("deleted file ") || line.startsWith("similarity ") + ? "meta" + : "context", + content: line, + oldLine, + newLine + }) + + if (!line.startsWith("index ") && !line.startsWith("new file ") && !line.startsWith("deleted file ") && !line.startsWith("similarity ")) { + oldLine += 1 + newLine += 1 + } + }) + + return files +} + +function buildUntrackedDiff(root, relativePath) { + const abs = path.join(root, relativePath) + const content = fs.existsSync(abs) ? fs.readFileSync(abs, "utf8") : "" + const lines = content.split(/\r?\n/) + const body = lines.map(line => `+${line}`).join("\n") + + return [ + `diff --git a/${relativePath} b/${relativePath}`, + "new file mode 100644", + "index 0000000..0000000", + "--- /dev/null", + `+++ b/${relativePath}`, + `@@ -0,0 +1,${lines.length} @@`, + body + ].join("\n") +} + +async function getWorkingTreeDiff(root, options = {}) { + const filePath = options.path + + if (options.untracked && filePath) { + const text = buildUntrackedDiff(root, filePath) + return { text, files: parseUnifiedDiff(text) } + } + + const args = ["diff", "--find-renames", "--no-ext-diff", "--unified=80"] + if (options.cached) args.push("--cached") + if (filePath) args.push("--", filePath) + + const result = await runGit(root, args, { allowFailure: true }) + + if (!result.success && result.code !== 1) { + throw new Error(result.error || "Could not read diff") + } + + return { + text: result.stdout, + files: parseUnifiedDiff(result.stdout) + } +} + +async function getCommitDiff(root, options = {}) { + const args = ["show", "--format=", "--find-renames", "--no-ext-diff", "--unified=80", options.commit] + + if (options.path) { + args.push("--", options.path) + } + + const result = await runGit(root, args, { allowFailure: true }) + + if (!result.success && result.code !== 1) { + throw new Error(result.error || "Could not read commit diff") + } + + return { + text: result.stdout, + files: parseUnifiedDiff(result.stdout) + } +} + +module.exports = { + getWorkingTreeDiff, + getCommitDiff, + parseUnifiedDiff +} diff --git a/app/git/GitGraphBuilder.js b/app/git/GitGraphBuilder.js new file mode 100644 index 0000000..f546f8c --- /dev/null +++ b/app/git/GitGraphBuilder.js @@ -0,0 +1,96 @@ +const { parseRefs } = require("./GitLogParser.js") + +function getOrCreateLane(activeLanes, hash) { + let index = activeLanes.indexOf(hash) + if (index !== -1) return index + + index = activeLanes.findIndex(value => value === null) + if (index === -1) index = activeLanes.length + + activeLanes[index] = hash + return index +} + +function buildGraph(commits) { + const activeLanes = [] + const nodes = [] + const edges = [] + + commits.forEach((commit, rowIndex) => { + const laneIndex = getOrCreateLane(activeLanes, commit.fullHash) + const parentConnections = [] + + commit.parents.forEach((parentHash, parentIndex) => { + const parentLane = parentIndex === 0 + ? laneIndex + : getOrCreateLane(activeLanes, parentHash) + + parentConnections.push({ + fromLane: laneIndex, + toLane: parentLane, + parentHash + }) + + edges.push({ + from: commit.fullHash, + to: parentHash, + fromLane: laneIndex, + toLane: parentLane, + merge: parentIndex > 0 + }) + }) + + if (commit.parents.length === 0) { + activeLanes[laneIndex] = null + } else { + activeLanes[laneIndex] = commit.parents[0] + commit.parents.slice(1).forEach(parentHash => { + getOrCreateLane(activeLanes, parentHash) + }) + } + + nodes.push({ + ...commit, + laneIndex, + rowIndex, + connectionsToParents: parentConnections, + activeLanes: activeLanes.slice() + }) + }) + + return { + commits: nodes, + edges, + lanes: Math.max(1, ...nodes.map(node => node.laneIndex + 1), activeLanes.length) + } +} + +function parseDecoratedLog(raw) { + return raw + .split("\x1e") + .map(chunk => chunk.trim()) + .filter(Boolean) + .map(chunk => { + const fields = chunk.split("\x1f") + const refs = parseRefs(fields[5] || "") + + return { + fullHash: fields[0], + shortHash: fields[0]?.slice(0, 8), + parents: fields[1] ? fields[1].split(" ").filter(Boolean) : [], + author: fields[2], + date: fields[3], + message: fields[4] || "", + refs: refs.refs, + branchLabels: refs.branches, + remoteLabels: refs.remotes, + tags: refs.tags, + isHead: refs.head + } + }) +} + +module.exports = { + buildGraph, + parseDecoratedLog +} diff --git a/app/git/GitLogParser.js b/app/git/GitLogParser.js new file mode 100644 index 0000000..668f521 --- /dev/null +++ b/app/git/GitLogParser.js @@ -0,0 +1,99 @@ +const FIELD = "\x1f" +const RECORD = "\x1e" + +function parseRefs(refText = "") { + const refs = refText + .split(",") + .map(ref => ref.trim()) + .filter(Boolean) + + const branches = [] + const remotes = [] + const tags = [] + let head = false + + refs.forEach(ref => { + if (ref.startsWith("HEAD -> ")) { + head = true + branches.push(ref.slice("HEAD -> ".length)) + return + } + + if (ref === "HEAD") { + head = true + return + } + + if (ref.startsWith("tag: ")) { + tags.push(ref.slice("tag: ".length)) + return + } + + if (ref.includes("/")) { + remotes.push(ref) + } else { + branches.push(ref) + } + }) + + return { + refs, + branches, + remotes, + tags, + head + } +} + +function parseNameStatus(lines) { + return lines + .map(line => line.trim()) + .filter(Boolean) + .map(line => { + const parts = line.split("\t") + const status = parts.shift() + + return { + status, + path: parts[parts.length - 1] || "", + originalPath: parts.length > 1 ? parts[0] : null + } + }) + .filter(file => file.path) +} + +function parseLog(raw) { + return raw + .split(RECORD) + .map(chunk => chunk.trim()) + .filter(Boolean) + .map(chunk => { + const lines = chunk.split(/\r?\n/) + const fields = lines.shift().split(FIELD) + const refs = parseRefs(fields[6] || "") + + return { + fullHash: fields[0], + shortHash: fields[0]?.slice(0, 8), + parents: fields[1] ? fields[1].split(" ").filter(Boolean) : [], + author: fields[2], + email: fields[3], + date: fields[4], + message: fields[5] || "", + refs: refs.refs, + branchLabels: refs.branches, + remoteLabels: refs.remotes, + tags: refs.tags, + isHead: refs.head, + files: parseNameStatus(lines) + } + }) +} + +module.exports = { + FIELD, + RECORD, + parseRefs, + parseLog, + parseNameStatus +} diff --git a/app/git/GitRepositoryService.js b/app/git/GitRepositoryService.js new file mode 100644 index 0000000..0487bf9 --- /dev/null +++ b/app/git/GitRepositoryService.js @@ -0,0 +1,404 @@ +const fs = require("fs") +const path = require("path") +const { runGit, assertGitAvailable, getRepositoryRoot } = require("./GitCommandRunner.js") +const { parseStatusV2 } = require("./GitStatusParser.js") +const { FIELD, RECORD, parseLog } = require("./GitLogParser.js") +const { buildGraph, parseDecoratedLog } = require("./GitGraphBuilder.js") +const { getWorkingTreeDiff, getCommitDiff } = require("./GitDiffService.js") + +function cleanLimit(limit, fallback = 300) { + const number = Number(limit) + if (!Number.isInteger(number)) return fallback + return Math.min(Math.max(number, 20), 1000) +} + +function ok(result) { + return { success: true, result } +} + +function fail(error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } +} + +function ensurePath(root, filePath) { + if (!filePath || typeof filePath !== "string") { + throw new Error("Invalid file path") + } + + const normalized = filePath.replace(/\\/g, "/") + const abs = path.resolve(root, normalized) + const resolvedRoot = path.resolve(root) + + if (!abs.startsWith(resolvedRoot)) { + throw new Error("Path is outside Git repository") + } + + return normalized +} + +class GitRepositoryService { + async resolveRoot(rootPath) { + await assertGitAvailable(rootPath || process.cwd()) + return getRepositoryRoot(rootPath || process.cwd()) + } + + async status(rootPath) { + const root = await this.resolveRoot(rootPath) + const result = await runGit(root, ["status", "--porcelain=v2", "-z", "--branch", "--untracked-files=all"]) + + if (!result.success) { + throw new Error(result.error || "Could not read Git status") + } + + return { + root, + ...parseStatusV2(result.stdout) + } + } + + async hasUncommittedChanges(rootPath) { + const status = await this.status(rootPath) + return !status.isClean + } + + async stage(rootPath, filePath) { + const root = await this.resolveRoot(rootPath) + const target = ensurePath(root, filePath) + const result = await runGit(root, ["add", "--", target]) + if (!result.success) throw new Error(result.error || "Could not stage file") + return this.status(root) + } + + async unstage(rootPath, filePath) { + const root = await this.resolveRoot(rootPath) + const target = ensurePath(root, filePath) + let result = await runGit(root, ["restore", "--staged", "--", target], { allowFailure: true }) + + if (!result.success) { + result = await runGit(root, ["reset", "HEAD", "--", target], { allowFailure: true }) + } + + if (!result.success) throw new Error(result.error || "Could not unstage file") + return this.status(root) + } + + async stageAll(rootPath) { + const root = await this.resolveRoot(rootPath) + const result = await runGit(root, ["add", "-A"]) + if (!result.success) throw new Error(result.error || "Could not stage all files") + return this.status(root) + } + + async unstageAll(rootPath) { + const root = await this.resolveRoot(rootPath) + let result = await runGit(root, ["restore", "--staged", "."], { allowFailure: true }) + + if (!result.success) { + result = await runGit(root, ["reset", "HEAD"], { allowFailure: true }) + } + + if (!result.success) throw new Error(result.error || "Could not unstage files") + return this.status(root) + } + + async discard(rootPath, filePath, options = {}) { + const root = await this.resolveRoot(rootPath) + const target = ensurePath(root, filePath) + let result + + if (options.untracked) { + result = await runGit(root, ["clean", "-f", "--", target], { allowFailure: true }) + } else { + result = await runGit(root, ["restore", "--worktree", "--", target], { allowFailure: true }) + } + + if (!result.success) throw new Error(result.error || "Could not discard changes") + return this.status(root) + } + + async restore(rootPath, filePath) { + const root = await this.resolveRoot(rootPath) + const target = ensurePath(root, filePath) + const result = await runGit(root, ["restore", "--source=HEAD", "--staged", "--worktree", "--", target], { allowFailure: true }) + if (!result.success) throw new Error(result.error || "Could not restore file") + return this.status(root) + } + + async commit(rootPath, message) { + const root = await this.resolveRoot(rootPath) + const cleanMessage = String(message || "").trim() + + if (!cleanMessage) { + throw new Error("Commit message cannot be empty") + } + + const status = await this.status(root) + if (status.counts.staged === 0) { + throw new Error("There are no staged files to commit") + } + + const result = await runGit(root, ["commit", "-m", cleanMessage], { timeout: 60000, maxBuffer: 1024 * 1024 * 8 }) + if (!result.success) throw new Error(result.error || "Commit failed") + + return { + output: result.stdout || result.stderr, + status: await this.status(root) + } + } + + async diff(rootPath, options = {}) { + const root = await this.resolveRoot(rootPath) + if (options.path) ensurePath(root, options.path) + + return getWorkingTreeDiff(root, options) + } + + async commitDiff(rootPath, options = {}) { + const root = await this.resolveRoot(rootPath) + if (!options.commit) throw new Error("Missing commit hash") + if (options.path) ensurePath(root, options.path) + + return getCommitDiff(root, options) + } + + async log(rootPath, limit = 300) { + const root = await this.resolveRoot(rootPath) + const safeLimit = cleanLimit(limit) + const format = `${RECORD}%H${FIELD}%P${FIELD}%an${FIELD}%ae${FIELD}%ad${FIELD}%s${FIELD}%D` + + const result = await runGit(root, [ + "log", + "--all", + "--date=iso-strict", + "--find-renames", + `--max-count=${safeLimit}`, + `--pretty=format:${format}`, + "--name-status" + ], { maxBuffer: 1024 * 1024 * 32 }) + + if (!result.success) throw new Error(result.error || "Could not read Git history") + + return { + root, + limit: safeLimit, + commits: parseLog(result.stdout) + } + } + + async graph(rootPath, limit = 400) { + const root = await this.resolveRoot(rootPath) + const safeLimit = cleanLimit(limit, 400) + const format = `${RECORD}%H${FIELD}%P${FIELD}%an${FIELD}%ad${FIELD}%s${FIELD}%D` + + const result = await runGit(root, [ + "log", + "--all", + "--topo-order", + "--date=iso-strict", + `--max-count=${safeLimit}`, + `--pretty=format:${format}` + ], { maxBuffer: 1024 * 1024 * 20 }) + + if (!result.success) throw new Error(result.error || "Could not build Git graph") + + return { + root, + limit: safeLimit, + ...buildGraph(parseDecoratedLog(result.stdout)) + } + } + + async branches(rootPath) { + const root = await this.resolveRoot(rootPath) + const format = "%(refname:short)%00%(objectname:short)%00%(upstream:short)%00%(HEAD)%00%(committerdate:iso-strict)" + const local = await runGit(root, ["branch", "--format", format, "--sort=-committerdate"]) + const remote = await runGit(root, ["branch", "-r", "--format", format, "--sort=-committerdate"], { allowFailure: true }) + const status = await this.status(root) + + if (!local.success) throw new Error(local.error || "Could not read local branches") + + function parse(raw, type) { + return raw + .split(/\r?\n/) + .map(line => line.trim()) + .filter(Boolean) + .map(line => { + const [name, hash, upstream, head, date] = line.split("\0") + return { + name, + hash, + upstream: upstream || null, + current: head === "*", + date, + type + } + }) + } + + return { + root, + current: status.branch.current, + upstream: status.branch.upstream, + ahead: status.branch.ahead, + behind: status.branch.behind, + local: parse(local.stdout, "local"), + remote: remote.success ? parse(remote.stdout, "remote") : [] + } + } + + async checkout(rootPath, branch) { + const root = await this.resolveRoot(rootPath) + const result = await runGit(root, ["checkout", branch]) + if (!result.success) throw new Error(result.error || "Checkout failed") + return this.branches(root) + } + + async createBranch(rootPath, branch, checkout = true) { + const root = await this.resolveRoot(rootPath) + const cleanName = String(branch || "").trim() + if (!cleanName) throw new Error("Branch name cannot be empty") + + const args = checkout ? ["checkout", "-b", cleanName] : ["branch", cleanName] + const result = await runGit(root, args) + if (!result.success) throw new Error(result.error || "Could not create branch") + return this.branches(root) + } + + async deleteBranch(rootPath, branch) { + const root = await this.resolveRoot(rootPath) + const result = await runGit(root, ["branch", "-d", branch], { allowFailure: true }) + if (!result.success) throw new Error(result.error || "Could not delete branch") + return this.branches(root) + } + + async renameBranch(rootPath, oldName, newName) { + const root = await this.resolveRoot(rootPath) + const cleanName = String(newName || "").trim() + if (!cleanName) throw new Error("Branch name cannot be empty") + + const args = oldName ? ["branch", "-m", oldName, cleanName] : ["branch", "-m", cleanName] + const result = await runGit(root, args) + if (!result.success) throw new Error(result.error || "Could not rename branch") + return this.branches(root) + } + + async fetch(rootPath) { + const root = await this.resolveRoot(rootPath) + const result = await runGit(root, ["fetch", "--all", "--prune"], { timeout: 120000, maxBuffer: 1024 * 1024 * 12 }) + if (!result.success) throw new Error(result.error || "Fetch failed") + return result.stdout || result.stderr || "Fetch completed" + } + + async pull(rootPath) { + const root = await this.resolveRoot(rootPath) + const result = await runGit(root, ["pull"], { timeout: 120000, maxBuffer: 1024 * 1024 * 12 }) + if (!result.success) throw new Error(result.error || "Pull failed") + return result.stdout || result.stderr || "Pull completed" + } + + async push(rootPath, currentOnly = true) { + const root = await this.resolveRoot(rootPath) + const args = currentOnly ? ["push"] : ["push", "--all"] + const result = await runGit(root, args, { timeout: 120000, maxBuffer: 1024 * 1024 * 12 }) + if (!result.success) throw new Error(result.error || "Push failed") + return result.stdout || result.stderr || "Push completed" + } + + async merge(rootPath, branch) { + const root = await this.resolveRoot(rootPath) + const result = await runGit(root, ["merge", branch], { timeout: 120000, maxBuffer: 1024 * 1024 * 12, allowFailure: true }) + if (!result.success) { + return { + conflicted: true, + output: result.stdout || result.stderr || result.error, + status: await this.status(root) + } + } + return { conflicted: false, output: result.stdout || result.stderr, status: await this.status(root) } + } + + async rebase(rootPath, branch) { + const root = await this.resolveRoot(rootPath) + const result = await runGit(root, ["rebase", branch], { timeout: 120000, maxBuffer: 1024 * 1024 * 12, allowFailure: true }) + if (!result.success) { + return { + conflicted: true, + output: result.stdout || result.stderr || result.error, + status: await this.status(root) + } + } + return { conflicted: false, output: result.stdout || result.stderr, status: await this.status(root) } + } + + async blame(rootPath, filePath) { + const root = await this.resolveRoot(rootPath) + const target = ensurePath(root, filePath) + const abs = path.join(root, target) + const linesCount = fs.existsSync(abs) + ? fs.readFileSync(abs, "utf8").split(/\r?\n/).length + : 0 + + const result = await runGit(root, ["blame", "--line-porcelain", "--", target], { allowFailure: true, maxBuffer: 1024 * 1024 * 24 }) + + if (!result.success) { + return { + path: target, + lines: Array.from({ length: linesCount }, (_, index) => ({ + line: index + 1, + author: "Uncommitted", + shortHash: "00000000", + hash: "0000000000000000000000000000000000000000", + date: "", + summary: "Not committed yet" + })) + } + } + + const lines = [] + let current = null + + result.stdout.split(/\r?\n/).forEach(line => { + const header = line.match(/^([0-9a-f]{40})\s+\d+\s+(\d+)/) + if (header) { + current = { + hash: header[1], + shortHash: header[1].slice(0, 8), + line: Number(header[2]), + author: "", + authorTime: null, + date: "", + summary: "" + } + return + } + + if (!current) return + + if (line.startsWith("author ")) current.author = line.slice("author ".length) + if (line.startsWith("author-time ")) { + current.authorTime = Number(line.slice("author-time ".length)) + current.date = new Date(current.authorTime * 1000).toISOString().slice(0, 10) + } + if (line.startsWith("summary ")) current.summary = line.slice("summary ".length) + if (line.startsWith("\t")) { + lines.push(current) + current = null + } + }) + + return { path: target, lines } + } +} + +async function handleGitAction(action) { + try { + return ok(await action()) + } catch (error) { + return fail(error) + } +} + +module.exports = { + GitRepositoryService, + handleGitAction +} diff --git a/app/git/GitStatusParser.js b/app/git/GitStatusParser.js new file mode 100644 index 0000000..5c26a2a --- /dev/null +++ b/app/git/GitStatusParser.js @@ -0,0 +1,190 @@ +function emptyBranch() { + return { + current: null, + oid: null, + upstream: null, + ahead: 0, + behind: 0 + } +} + +function parseBranchLine(line, branch) { + if (line.startsWith("# branch.oid ")) { + branch.oid = line.slice("# branch.oid ".length).trim() + return + } + + if (line.startsWith("# branch.head ")) { + const head = line.slice("# branch.head ".length).trim() + branch.current = head === "(detached)" ? "DETACHED" : head + return + } + + if (line.startsWith("# branch.upstream ")) { + branch.upstream = line.slice("# branch.upstream ".length).trim() + return + } + + if (line.startsWith("# branch.ab ")) { + const match = line.match(/\+(\d+)\s+-(\d+)/) + if (match) { + branch.ahead = Number(match[1]) + branch.behind = Number(match[2]) + } + } +} + +function statusName(code) { + if (!code || code === ".") return "clean" + if (code === "?") return "untracked" + if (code === "M") return "modified" + if (code === "A") return "added" + if (code === "D") return "deleted" + if (code === "R") return "renamed" + if (code === "C") return "copied" + if (code === "U") return "conflicted" + return "modified" +} + +function isConflictXY(xy) { + return xy.includes("U") || ["AA", "DD", "AU", "UA", "DU", "UD"].includes(xy) +} + +function createEntry({ path, originalPath = null, xy = "..", rawType = "1" }) { + const index = xy[0] || "." + const worktree = xy[1] || "." + const conflicted = rawType === "u" || isConflictXY(xy) + + return { + path, + originalPath, + xy, + index, + worktree, + type: conflicted + ? "conflicted" + : originalPath + ? "renamed" + : statusName(worktree !== "." ? worktree : index), + indexStatus: statusName(index), + worktreeStatus: statusName(worktree), + staged: !conflicted && index !== "." && index !== "?", + changed: !conflicted && worktree !== "." && worktree !== "?", + untracked: xy === "??", + conflicted + } +} + +function addToGroups(groups, entry) { + if (entry.conflicted) { + groups.conflicts.push({ ...entry, group: "conflicts", label: "Conflicted" }) + return + } + + if (entry.untracked) { + groups.untracked.push({ ...entry, group: "untracked", label: "Untracked" }) + return + } + + if (entry.staged) { + groups.staged.push({ + ...entry, + group: "staged", + label: entry.indexStatus === "clean" ? "Staged" : entry.indexStatus + }) + } + + if (entry.changed) { + groups.changes.push({ + ...entry, + group: "changes", + label: entry.worktreeStatus === "clean" ? "Modified" : entry.worktreeStatus + }) + } +} + +function parseStatusV2(raw) { + const branch = emptyBranch() + const entries = [] + const groups = { + staged: [], + changes: [], + untracked: [], + conflicts: [] + } + + const records = raw.split("\0").filter(Boolean) + + for (let i = 0; i < records.length; i++) { + const record = records[i] + + if (record.startsWith("# ")) { + parseBranchLine(record, branch) + continue + } + + if (record.startsWith("? ")) { + const entry = createEntry({ path: record.slice(2), xy: "??", rawType: "?" }) + entries.push(entry) + addToGroups(groups, entry) + continue + } + + if (record.startsWith("1 ")) { + const parts = record.split(" ") + const entry = createEntry({ + xy: parts[1], + path: parts.slice(8).join(" "), + rawType: "1" + }) + entries.push(entry) + addToGroups(groups, entry) + continue + } + + if (record.startsWith("2 ")) { + const parts = record.split(" ") + const originalPath = records[i + 1] || null + if (originalPath) i += 1 + + const entry = createEntry({ + xy: parts[1], + path: parts.slice(9).join(" "), + originalPath, + rawType: "2" + }) + entries.push(entry) + addToGroups(groups, entry) + continue + } + + if (record.startsWith("u ")) { + const parts = record.split(" ") + const entry = createEntry({ + xy: parts[1], + path: parts.slice(10).join(" "), + rawType: "u" + }) + entries.push(entry) + addToGroups(groups, entry) + } + } + + return { + branch, + entries, + groups, + isClean: entries.length === 0, + counts: { + staged: groups.staged.length, + changes: groups.changes.length, + untracked: groups.untracked.length, + conflicts: groups.conflicts.length, + total: entries.length + } + } +} + +module.exports = { + parseStatusV2 +} diff --git a/app/git/gitIpc.js b/app/git/gitIpc.js new file mode 100644 index 0000000..4010767 --- /dev/null +++ b/app/git/gitIpc.js @@ -0,0 +1,35 @@ +const { ipcMain } = require("electron") +const { GitRepositoryService, handleGitAction } = require("./GitRepositoryService.js") + +const gitService = new GitRepositoryService() + +function registerGitIpc() { + ipcMain.handle("git-status", (_, rootPath) => handleGitAction(() => gitService.status(rootPath))) + ipcMain.handle("git-log", (_, rootPath, limit) => handleGitAction(() => gitService.log(rootPath, limit))) + ipcMain.handle("git-graph", (_, rootPath, limit) => handleGitAction(() => gitService.graph(rootPath, limit))) + ipcMain.handle("git-branches", (_, rootPath) => handleGitAction(() => gitService.branches(rootPath))) + ipcMain.handle("git-diff", (_, rootPath, options) => handleGitAction(() => gitService.diff(rootPath, options || {}))) + ipcMain.handle("git-commit-diff", (_, rootPath, options) => handleGitAction(() => gitService.commitDiff(rootPath, options || {}))) + ipcMain.handle("git-blame", (_, rootPath, filePath) => handleGitAction(() => gitService.blame(rootPath, filePath))) + ipcMain.handle("git-stage", (_, rootPath, filePath) => handleGitAction(() => gitService.stage(rootPath, filePath))) + ipcMain.handle("git-unstage", (_, rootPath, filePath) => handleGitAction(() => gitService.unstage(rootPath, filePath))) + ipcMain.handle("git-stage-all", (_, rootPath) => handleGitAction(() => gitService.stageAll(rootPath))) + ipcMain.handle("git-unstage-all", (_, rootPath) => handleGitAction(() => gitService.unstageAll(rootPath))) + ipcMain.handle("git-discard", (_, rootPath, filePath, options) => handleGitAction(() => gitService.discard(rootPath, filePath, options || {}))) + ipcMain.handle("git-restore", (_, rootPath, filePath) => handleGitAction(() => gitService.restore(rootPath, filePath))) + ipcMain.handle("git-commit", (_, rootPath, message) => handleGitAction(() => gitService.commit(rootPath, message))) + ipcMain.handle("git-checkout", (_, rootPath, branch) => handleGitAction(() => gitService.checkout(rootPath, branch))) + ipcMain.handle("git-create-branch", (_, rootPath, branch, checkout) => handleGitAction(() => gitService.createBranch(rootPath, branch, checkout))) + ipcMain.handle("git-delete-branch", (_, rootPath, branch) => handleGitAction(() => gitService.deleteBranch(rootPath, branch))) + ipcMain.handle("git-rename-branch", (_, rootPath, oldName, newName) => handleGitAction(() => gitService.renameBranch(rootPath, oldName, newName))) + ipcMain.handle("git-fetch", (_, rootPath) => handleGitAction(() => gitService.fetch(rootPath))) + ipcMain.handle("git-pull", (_, rootPath) => handleGitAction(() => gitService.pull(rootPath))) + ipcMain.handle("git-push", (_, rootPath, currentOnly) => handleGitAction(() => gitService.push(rootPath, currentOnly))) + ipcMain.handle("git-merge", (_, rootPath, branch) => handleGitAction(() => gitService.merge(rootPath, branch))) + ipcMain.handle("git-rebase", (_, rootPath, branch) => handleGitAction(() => gitService.rebase(rootPath, branch))) + ipcMain.handle("git-has-changes", (_, rootPath) => handleGitAction(() => gitService.hasUncommittedChanges(rootPath))) +} + +module.exports = { + registerGitIpc +} diff --git a/app/preload.js b/app/preload.js index 2c414bb..b931719 100644 --- a/app/preload.js +++ b/app/preload.js @@ -71,6 +71,31 @@ contextBridge.exposeInMainWorld('electron', { return () => ipcRenderer.removeListener("terminal-result", listener) }, + gitStatus: (rootPath) => ipcRenderer.invoke("git-status", rootPath), + gitLog: (rootPath, limit) => ipcRenderer.invoke("git-log", rootPath, limit), + gitGraph: (rootPath, limit) => ipcRenderer.invoke("git-graph", rootPath, limit), + gitBranches: (rootPath) => ipcRenderer.invoke("git-branches", rootPath), + gitDiff: (rootPath, options) => ipcRenderer.invoke("git-diff", rootPath, options), + gitCommitDiff: (rootPath, options) => ipcRenderer.invoke("git-commit-diff", rootPath, options), + gitBlame: (rootPath, filePath) => ipcRenderer.invoke("git-blame", rootPath, filePath), + gitStage: (rootPath, filePath) => ipcRenderer.invoke("git-stage", rootPath, filePath), + gitUnstage: (rootPath, filePath) => ipcRenderer.invoke("git-unstage", rootPath, filePath), + gitStageAll: (rootPath) => ipcRenderer.invoke("git-stage-all", rootPath), + gitUnstageAll: (rootPath) => ipcRenderer.invoke("git-unstage-all", rootPath), + gitDiscard: (rootPath, filePath, options) => ipcRenderer.invoke("git-discard", rootPath, filePath, options), + gitRestore: (rootPath, filePath) => ipcRenderer.invoke("git-restore", rootPath, filePath), + gitCommit: (rootPath, message) => ipcRenderer.invoke("git-commit", rootPath, message), + gitCheckout: (rootPath, branch) => ipcRenderer.invoke("git-checkout", rootPath, branch), + gitCreateBranch: (rootPath, branch, checkout = true) => ipcRenderer.invoke("git-create-branch", rootPath, branch, checkout), + gitDeleteBranch: (rootPath, branch) => ipcRenderer.invoke("git-delete-branch", rootPath, branch), + gitRenameBranch: (rootPath, oldName, newName) => ipcRenderer.invoke("git-rename-branch", rootPath, oldName, newName), + gitFetch: (rootPath) => ipcRenderer.invoke("git-fetch", rootPath), + gitPull: (rootPath) => ipcRenderer.invoke("git-pull", rootPath), + gitPush: (rootPath, currentOnly = true) => ipcRenderer.invoke("git-push", rootPath, currentOnly), + gitMerge: (rootPath, branch) => ipcRenderer.invoke("git-merge", rootPath, branch), + gitRebase: (rootPath, branch) => ipcRenderer.invoke("git-rebase", rootPath, branch), + gitHasChanges: (rootPath) => ipcRenderer.invoke("git-has-changes", rootPath), + requestExtensions: () => ipcRenderer.invoke("request-extensions"), requestExtension: (name) => ipcRenderer.invoke("request-extension", name), createDebuggerWindow: () => ipcRenderer.invoke("create-debugger-window"), diff --git a/app/renderer.js b/app/renderer.js index 9585b74..ea1568a 100644 --- a/app/renderer.js +++ b/app/renderer.js @@ -41,6 +41,7 @@ import { bindFileClicks } from "../assets/js/explorerTree/handlers/bindFileClick import { setupSegmentedControl } from "../assets/js/handlers/segmentedControlHandler.js" import { getAddBugModal } from "../assets/js/modals/addBugModal.js" +import { initGitPanel } from "../assets/js/git/GitPanel.js" let isSaveAviable = true @@ -136,6 +137,7 @@ document.addEventListener("DOMContentLoaded", async () => { handleOnWheelScrollX() const pathContext = {} + const gitPanel = initGitPanel({ pathContext }) document.querySelector(".code-start__main-logo").src = appIcon @@ -297,6 +299,7 @@ document.addEventListener("DOMContentLoaded", async () => { rec.new = false; rec.tabEl.classList.remove("not-saved"); updateTabPath(currentPath, newPath, newName); + gitPanel.refresh() } return; @@ -306,6 +309,7 @@ document.addEventListener("DOMContentLoaded", async () => { if (saveStatus.success) { rec.tabEl.classList.remove("not-saved"); addToHistory("file-saved", currentPath.split(/[\\/]/).pop(), currentPath); + gitPanel.refresh() } } @@ -366,7 +370,7 @@ document.addEventListener("DOMContentLoaded", async () => { l.remove() - openFolder( + await openFolder( { pathRoot: openedFile, filesPanel: filesPanel, @@ -375,6 +379,8 @@ document.addEventListener("DOMContentLoaded", async () => { settings: settings } ) + + gitPanel.refresh() }) }) }) diff --git a/assets/css/components/git.css b/assets/css/components/git.css new file mode 100644 index 0000000..b53eb1e --- /dev/null +++ b/assets/css/components/git.css @@ -0,0 +1,778 @@ +.git-panel { + color: var(--text-color); + display: flex; + flex-direction: column; + gap: 12px; + height: 100%; + min-height: 0; + overflow: auto; + padding: 0 12px 16px; + box-sizing: border-box; +} + +.git-panel__header { + display: flex; + flex-direction: column; + gap: 9px; + padding-top: 10px; +} + +.git-branch-chip, +.git-ahead-behind, +.git-toolbar, +.git-actions-row, +.git-commit-box__title, +.git-file-row, +.git-branch-row, +.git-change-group__header>div { + align-items: center; + display: flex; +} + +.git-branch-chip { + background: var(--block-divider-border-color); + border: 1px solid var(--topbar-menu-item-hover-bg); + border-radius: 7px; + font-size: 13px; + font-weight: var(--bold-font-weight); + gap: 7px; + padding: 8px 10px; +} + +.git-branch-chip .material-symbols-rounded { + color: #6bdc8b; + font-size: 17px; +} + +.git-ahead-behind { + color: var(--text-color-muted); + font-size: 12px; + gap: 8px; + min-width: 0; +} + +.git-ahead-behind span:first-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.git-ahead-behind .active { + color: #57d4ff; +} + +.git-toolbar, +.git-actions-row { + gap: 6px; + flex-wrap: wrap; +} + +.git-icon-btn, +.git-button { + align-items: center; + background: var(--block-divider-border-color); + border: 1px solid transparent; + border-radius: 6px; + color: var(--text-color); + cursor: pointer; + display: inline-flex; + font-size: 12px; + justify-content: center; + min-height: 28px; + outline: none; + transition: .18s; +} + +.git-icon-btn { + height: 28px; + width: 30px; +} + +.git-button { + padding: 6px 10px; +} + +.git-button.primary { + background: #2f65f5; + font-weight: var(--bold-font-weight); +} + +.git-icon-btn:hover, +.git-button:hover { + border-color: var(--topbar-menu-item-hover-bg); + filter: brightness(1.1); +} + +.git-icon-btn:disabled, +.git-button:disabled { + cursor: default; + opacity: .4; +} + +.git-icon-btn .material-symbols-rounded { + font-size: 17px; +} + +.git-view-switch { + background: transparent; + display: grid; + flex: 0 0 auto; + gap: 6px; + grid-template-columns: repeat(auto-fit, minmax(76px, 1fr)); + margin-bottom: 0; + overflow: visible; + padding: 0 6px; + box-sizing: border-box; + width: 100%; +} + +.git-view-switch>label { + background: var(--block-divider-border-color); + border: 1px solid var(--topbar-menu-item-hover-bg); + border-radius: 6px; + box-sizing: border-box; + height: 42px; + justify-content: center; + min-width: 0; + overflow: hidden; + padding: 0 6px; + transition: .16s; +} + +.git-view-switch>label::before, +.git-view-switch>label:last-of-type::before { + display: none; + content: none; +} + +.git-view-switch>input:checked + label { + background: var(--segment-label); + border-color: rgba(255, 255, 255, .26); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .04); +} + +.git-view-switch label span { + font-size: 12px; + letter-spacing: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.git-view-switch>label:hover { + background: var(--topbar-menu-item-hover-bg); +} + +.git-view { + display: flex; + flex-direction: column; + gap: 12px; + min-height: 0; +} + +.git-panel__loading { + align-items: center; + background: var(--body-color-transparent); + border: 1px solid var(--block-divider-border-color); + border-radius: 8px; + bottom: 14px; + display: flex; + font-size: 12px; + gap: 8px; + left: 14px; + padding: 8px 10px; + position: sticky; + z-index: 5; +} + +.git-panel__loading .material-symbols-rounded { + animation: git-spin 1s linear infinite; + font-size: 16px; +} + +.git-change-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.git-change-group__header { + color: var(--text-color-muted); + font-size: 12px; + font-weight: var(--bold-font-weight); + text-transform: uppercase; +} + +.git-change-group__header>div { + gap: 6px; +} + +.git-change-group__header .material-symbols-rounded { + font-size: 15px; +} + +.git-count { + background: var(--topbar-menu-item-hover-bg); + border-radius: 999px; + color: var(--text-color); + font-size: 10px; + min-width: 16px; + padding: 1px 5px; + text-align: center; +} + +.git-change-group__body { + display: flex; + flex-direction: column; + gap: 3px; +} + +.git-group-empty, +.git-empty-state { + color: var(--text-color-muted); + font-size: 12px; + padding: 8px; +} + +.git-empty-state { + align-items: center; + border: 1px dashed var(--block-divider-border-color); + border-radius: 8px; + display: flex; + flex-direction: column; + gap: 10px; + justify-content: center; + min-height: 130px; + text-align: center; +} + +.git-empty-state.error { + color: var(--danger); +} + +.git-file-row { + background: transparent; + border-radius: 6px; + gap: 5px; + min-height: 34px; + position: relative; + transition: .16s; +} + +.git-file-row:hover { + background: var(--topbar-menu-item-hover-bg); +} + +.git-file-row__main { + align-items: center; + background: transparent; + border: 0; + color: var(--text-color); + cursor: pointer; + display: flex; + flex: 1; + gap: 8px; + min-width: 0; + padding: 6px; + width: 100%; + text-align: left; +} + +.git-file-row__text, +.git-branch-row__main span:last-child { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-width: 0; +} + +.git-file-row__name, +.git-branch-row__name, +.git-commit-row__message { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.git-file-row__name { + font-size: 13px; + font-weight: var(--medium-font-weight); +} + +.git-file-row__dir, +.git-branch-row__meta, +.git-commit-row__meta { + color: var(--text-color-muted); + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.git-file-row__actions, +.git-branch-row__actions { + display: flex; + gap: 3px; + opacity: 0; + padding-right: 4px; + transition: .16s; +} + +.git-file-row__actions { + background: var(--body-color-transparent); + border: 1px solid var(--block-divider-border-color); + border-radius: 6px; + display: none; + padding: 3px; + pointer-events: none; + position: absolute; + right: 4px; + top: 50%; + transform: translateY(-50%); +} + +.git-file-row:hover .git-file-row__actions, +.git-branch-row:hover .git-branch-row__actions { + opacity: 1; +} + +.git-file-row:hover .git-file-row__actions { + display: flex; + pointer-events: auto; +} + +.git-status-dot { + border-radius: 999px; + flex: 0 0 auto; + height: 8px; + width: 8px; +} + +.git-status-label { + border-radius: 5px; + font-size: 10px; + font-weight: var(--bold-font-weight); + padding: 2px 5px; + text-transform: uppercase; +} + +.git-status-dot.modified, +.git-status-label.modified { + background: #4c8dff33; + color: #6fa4ff; +} + +.git-status-dot.added, +.git-status-dot.untracked, +.git-status-label.added, +.git-status-label.untracked { + background: #6bdc8b33; + color: #6bdc8b; +} + +.git-status-dot.deleted, +.git-status-label.deleted { + background: #ff6b8a33; + color: #ff6b8a; +} + +.git-status-dot.renamed, +.git-status-label.renamed { + background: #a883ff33; + color: #a883ff; +} + +.git-status-dot.conflicted, +.git-status-label.conflicted { + background: #ffb34733; + color: #ffb347; +} + +.git-status-dot.clean, +.git-status-label.clean { + background: #7b7b7b33; + color: var(--text-color-muted); +} + +.git-clean-state { + align-items: center; + color: var(--text-color-muted); + display: flex; + font-size: 12px; + gap: 8px; + padding: 8px 2px; +} + +.git-commit-box { + border-top: 1px solid var(--block-divider-border-color); + display: flex; + flex-direction: column; + gap: 8px; + padding-top: 12px; +} + +.git-commit-box textarea { + box-sizing: border-box; + min-height: 76px; +} + +.git-commit-box__title { + justify-content: space-between; + color: var(--text-color-muted); + font-size: 12px; +} + +.git-history-list, +.git-branch-section { + display: flex; + flex-direction: column; + gap: 7px; +} + +.git-commit-row { + background: var(--block-divider-border-color); + border: 1px solid transparent; + border-radius: 7px; + overflow: hidden; +} + +.git-commit-row:hover { + border-color: var(--topbar-menu-item-hover-bg); +} + +.git-commit-row__main { + background: transparent; + border: 0; + color: var(--text-color); + cursor: pointer; + display: flex; + flex-direction: column; + gap: 5px; + padding: 9px; + text-align: left; + width: 100%; +} + +.git-ref-row { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.git-ref-label { + border-radius: 999px; + display: inline-flex; + font-size: 10px; + font-weight: var(--bold-font-weight); + line-height: 1; + padding: 4px 6px; + white-space: nowrap; +} + +.git-ref-label.branch, +.git-ref-label.head { + background: #4c8dff30; + color: #7eb0ff; +} + +.git-ref-label.remote { + background: #6bdc8b2c; + color: #6bdc8b; +} + +.git-ref-label.tag { + background: #ffb34733; + color: #ffb347; +} + +.git-commit-files { + border-top: 1px solid var(--body-color); + display: flex; + flex-direction: column; + gap: 2px; + padding: 6px 9px; +} + +.git-commit-files button { + align-items: center; + background: transparent; + border: 0; + color: var(--text-color-muted); + cursor: pointer; + display: flex; + font-size: 11px; + gap: 6px; + min-width: 0; + padding: 3px 0; + text-align: left; +} + +.git-commit-files button:hover { + color: var(--text-color); +} + +.git-commit-files__more { + color: var(--text-color-muted); + font-size: 11px; +} + +.git-branch-create { + display: grid; + gap: 6px; + grid-template-columns: 1fr auto; +} + +.git-branch-create input { + box-sizing: border-box; + height: 32px; + padding: 6px 9px; +} + +.git-branch-section h3 { + color: var(--text-color-muted); + font-size: 12px; + margin: 0; + text-transform: uppercase; +} + +.git-branch-row { + background: var(--block-divider-border-color); + border: 1px solid transparent; + border-radius: 7px; + gap: 6px; + justify-content: space-between; + min-height: 42px; + padding: 6px; +} + +.git-branch-row.current { + border-color: #6bdc8b55; +} + +.git-branch-row__main { + align-items: center; + display: flex; + gap: 8px; + min-width: 0; +} + +.git-branch-row__main .material-symbols-rounded { + color: #6bdc8b; + font-size: 17px; +} + +.git-graph-host { + min-height: 220px; + overflow: auto; +} + +.git-graph { + display: grid; + grid-template-columns: calc(var(--git-graph-lanes) * 22px + 22px) minmax(0, 1fr); + min-width: 520px; + position: relative; +} + +.git-graph__lines { + position: relative; +} + +.git-graph-svg { + left: 0; + overflow: visible; + position: absolute; + top: 0; +} + +.git-graph-svg path { + fill: none; + opacity: .8; + stroke-linecap: round; + stroke-width: 2; +} + +.git-graph-svg path.merge { + opacity: .55; +} + +.git-graph-svg circle { + stroke: var(--body-color); + stroke-width: 2; +} + +.git-graph-svg circle.head { + stroke: var(--text-color); +} + +.git-graph__rows { + display: flex; + flex-direction: column; +} + +.git-graph-row { + align-items: flex-start; + background: transparent; + border: 0; + border-bottom: 1px solid var(--block-divider-border-color); + color: var(--text-color); + cursor: pointer; + display: grid; + gap: 3px; + min-height: 42px; + padding: 5px 6px; + text-align: left; +} + +.git-graph-row:hover { + background: var(--topbar-menu-item-hover-bg); +} + +.git-graph-row__message, +.git-graph-row__meta { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.git-graph-row__message { + font-size: 12px; + font-weight: var(--medium-font-weight); +} + +.git-graph-row__meta { + color: var(--text-color-muted); + font-size: 10px; +} + +.git-graph-row__refs { + display: flex; + flex-wrap: wrap; + gap: 3px; +} + +.git-diff-window { + max-height: none !important; +} + +.git-diff-viewer { + display: flex; + flex-direction: column; + gap: 14px; + width: 100%; +} + +.git-diff-file { + border: 1px solid var(--block-divider-border-color); + border-radius: 7px; + overflow: hidden; +} + +.git-diff-file__header { + align-items: center; + background: var(--block-divider-border-color); + display: flex; + font-size: 12px; + gap: 7px; + padding: 8px 10px; +} + +.git-diff-file__header .material-symbols-rounded { + font-size: 16px; +} + +.git-diff-file__body { + background: var(--code-background); + font-family: var(--code-font); + font-size: 12px; + overflow: auto; +} + +.git-diff-line { + display: grid; + grid-template-columns: 52px 52px 22px minmax(0, 1fr); + min-height: 20px; + white-space: pre; +} + +.git-diff-line code { + color: var(--text-color); + font-family: var(--code-font); + overflow: visible; + padding: 2px 8px; +} + +.git-diff-line__num, +.git-diff-line__prefix { + color: var(--text-color-muted); + padding: 2px 6px; + text-align: right; + user-select: none; +} + +.git-diff-line__prefix { + text-align: center; +} + +.git-diff-line.added { + background: rgba(50, 190, 116, .13); +} + +.git-diff-line.added .git-diff-line__prefix, +.git-diff-line.added code { + color: #7ee2a0; +} + +.git-diff-line.removed { + background: rgba(255, 88, 116, .13); +} + +.git-diff-line.removed .git-diff-line__prefix, +.git-diff-line.removed code { + color: #ff8ea2; +} + +.git-diff-line.hunk { + background: rgba(76, 141, 255, .14); +} + +.git-diff-line.hunk code, +.git-diff-line.hunk .git-diff-line__prefix { + color: #83b0ff; +} + +.git-diff-line.meta { + background: rgba(255, 255, 255, .04); +} + +.git-diff-line.meta code { + color: var(--text-color-muted); +} + +.git-blame-overlay { + left: 0; + pointer-events: none; + position: fixed; + top: 0; + z-index: 12; +} + +.git-blame-inline { + background: var(--body-color-transparent); + border: 1px solid var(--block-divider-border-color); + border-radius: 999px; + color: var(--text-color-muted); + font-family: var(--main-font); + font-size: 10px; + max-width: 240px; + overflow: hidden; + padding: 2px 7px; + position: fixed; + text-overflow: ellipsis; + white-space: nowrap; +} + +@keyframes git-spin { + to { + transform: rotate(360deg); + } +} diff --git a/assets/css/main.css b/assets/css/main.css index 1f5657c..5cb4920 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -1,6 +1,7 @@ @import url('modals.css'); @import url("./window/topWindowList.css"); @import url("components/codeWrapper.css"); +@import url("components/git.css"); @import url("auth.css"); :root { @@ -458,8 +459,8 @@ input~span { .explorer__resize-handle { position: absolute; top: 0; - right: -10px; - width: 8px; + right: 0; + width: 10px; height: 100%; cursor: ew-resize; z-index: 20; @@ -468,7 +469,7 @@ input~span { content: ""; position: absolute; top: 50%; - right: 3px; + right: 4px; width: 2px; height: 42px; transform: translateY(-50%); diff --git a/assets/js/git/GitBlameOverlay.js b/assets/js/git/GitBlameOverlay.js new file mode 100644 index 0000000..f18c454 --- /dev/null +++ b/assets/js/git/GitBlameOverlay.js @@ -0,0 +1,98 @@ +import { currentPath, tabsByPath } from "../../components/tabHandler.js" +import { escapeHtml } from "../lib.js" + +function relativePath(rootPath, filePath) { + if (!rootPath || !filePath) return null + + const root = rootPath.replaceAll("\\", "/").replace(/\/$/, "") + const file = filePath.replaceAll("\\", "/") + + if (!file.startsWith(root)) return null + return file.slice(root.length).replace(/^\//, "") +} + +export class GitBlameOverlay { + constructor(pathContext) { + this.pathContext = pathContext + this.enabled = false + this.overlay = document.createElement("div") + this.overlay.className = "git-blame-overlay hidden" + document.body.appendChild(this.overlay) + this.afterRenderHandler = () => this.render() + this.scrollHandler = () => this.render() + this.resizeHandler = () => this.render() + } + + async toggle() { + if (this.enabled) { + this.disable() + return + } + + await this.enable() + } + + async enable() { + const rec = tabsByPath.get(currentPath) + const rel = relativePath(this.pathContext.rootPath, currentPath) + if (!rec || !rel) return + + const response = await window.electron.gitBlame(this.pathContext.rootPath, rel) + if (!response.success) { + window.alert(response.error || "Could not load blame") + return + } + + this.enabled = true + this.editor = rec.editor + this.lines = response.result.lines + this.overlay.classList.remove("hidden") + this.editor.renderer.on("afterRender", this.afterRenderHandler) + this.editor.session.on("changeScrollTop", this.scrollHandler) + window.addEventListener("resize", this.resizeHandler) + this.render() + } + + disable() { + this.enabled = false + this.overlay.classList.add("hidden") + this.overlay.innerHTML = "" + + if (this.editor) { + this.editor.renderer.off("afterRender", this.afterRenderHandler) + this.editor.session.off("changeScrollTop", this.scrollHandler) + } + + window.removeEventListener("resize", this.resizeHandler) + this.editor = null + this.lines = [] + } + + render() { + if (!this.enabled || !this.editor || !this.lines) return + + const renderer = this.editor.renderer + const firstRow = renderer.layerConfig.firstRow + const lastRow = renderer.layerConfig.lastRow + const html = [] + + for (let row = firstRow; row <= lastRow; row++) { + const blame = this.lines[row] + if (!blame) continue + + const line = this.editor.session.getLine(row) + const coords = renderer.textToScreenCoordinates(row, Math.max(1, line.length + 2)) + const top = coords.pageY - renderer.lineHeight + 2 + const left = Math.min(coords.pageX + 12, window.innerWidth - 260) + const text = `${blame.author || "Unknown"} · ${blame.shortHash} · ${blame.date}` + + html.push(` +
${escapeHtml(content)}
+