From 6626850537c50fe19aa1395ddbdfc79440ec505f Mon Sep 17 00:00:00 2001 From: matt00ff Date: Mon, 18 May 2026 02:12:45 +0300 Subject: [PATCH 1/2] Add CodeMotion IDE: Git-support, localization fixes and resizable sidebar improvements --- app/git/GitCommandRunner.js | 71 +++ app/git/GitDiffService.js | 159 +++++++ app/git/GitGraphBuilder.js | 96 ++++ app/git/GitLogParser.js | 99 ++++ app/git/GitRepositoryService.js | 404 ++++++++++++++++ app/git/GitStatusParser.js | 190 ++++++++ app/git/gitIpc.js | 35 ++ app/preload.js | 25 + app/renderer.js | 8 +- assets/css/components/git.css | 766 ++++++++++++++++++++++++++++++ assets/css/main.css | 7 +- assets/js/git/GitBlameOverlay.js | 98 ++++ assets/js/git/GitDiffViewer.js | 61 +++ assets/js/git/GitGraphView.js | 98 ++++ assets/js/git/GitPanel.js | 771 +++++++++++++++++++++++++++++++ html/index.html | 8 +- languages/en.json | 171 ++++++- languages/ru.json | 3 +- main.js | 1 + 19 files changed, 3063 insertions(+), 8 deletions(-) create mode 100644 app/git/GitCommandRunner.js create mode 100644 app/git/GitDiffService.js create mode 100644 app/git/GitGraphBuilder.js create mode 100644 app/git/GitLogParser.js create mode 100644 app/git/GitRepositoryService.js create mode 100644 app/git/GitStatusParser.js create mode 100644 app/git/gitIpc.js create mode 100644 assets/css/components/git.css create mode 100644 assets/js/git/GitBlameOverlay.js create mode 100644 assets/js/git/GitDiffViewer.js create mode 100644 assets/js/git/GitGraphView.js create mode 100644 assets/js/git/GitPanel.js 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..1fe6004 --- /dev/null +++ b/assets/css/components/git.css @@ -0,0 +1,766 @@ +.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: 4px; + grid-template-columns: repeat(auto-fit, minmax(76px, 1fr)); + margin-bottom: 0; + overflow: visible; + padding: 0; +} + +.git-view-switch>label { + background: var(--block-divider-border-color); + border: 1px solid transparent; + border-radius: 6px; + box-sizing: border-box; + min-width: 0; + padding: 8px 6px; + transition: .16s; +} + +.git-view-switch>label:last-of-type::before { + display: none; +} + +.git-view-switch>input:checked + label { + background: var(--segment-label); + border-color: var(--topbar-menu-item-hover-bg); +} + +.git-view-switch label span { + font-size: 12px; + letter-spacing: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.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(text)} +
+ `) + } + + this.overlay.innerHTML = html.join("") + } +} diff --git a/assets/js/git/GitDiffViewer.js b/assets/js/git/GitDiffViewer.js new file mode 100644 index 0000000..c7b077a --- /dev/null +++ b/assets/js/git/GitDiffViewer.js @@ -0,0 +1,61 @@ +import { BottomWindow } from "../handlers/BottomWindowHandler.js" +import { escapeHtml } from "../lib.js" + +function renderLine(line, index) { + const oldLine = line.oldLine == null ? "" : line.oldLine + const newLine = line.newLine == null ? "" : line.newLine + const prefix = line.type === "added" + ? "+" + : line.type === "removed" + ? "-" + : line.type === "hunk" + ? "@" + : "" + const content = line.type === "added" || line.type === "removed" + ? line.content.slice(1) + : line.content + + return ` +
+ ${oldLine} + ${newLine} + ${escapeHtml(prefix)} + ${escapeHtml(content)} +
+ ` +} + +function renderFile(file) { + const title = file.newPath || file.oldPath || file.header || "Diff" + + return ` +
+
+ difference + ${escapeHtml(title)} +
+
+ ${file.lines.map(renderLine).join("")} +
+
+ ` +} + +export function showGitDiff({ title = "Git Diff", diff }) { + const diffWindow = new BottomWindow("gitDiffViewer", { title }) + diffWindow.fullscreen() + diffWindow.show() + diffWindow.clear() + + if (!diff?.files?.length) { + diffWindow.set(`
No diff available
`) + return + } + + diffWindow.win.classList.add("git-diff-window") + diffWindow.set(` +
+ ${diff.files.map(renderFile).join("")} +
+ `) +} diff --git a/assets/js/git/GitGraphView.js b/assets/js/git/GitGraphView.js new file mode 100644 index 0000000..553f889 --- /dev/null +++ b/assets/js/git/GitGraphView.js @@ -0,0 +1,98 @@ +import { escapeHtml } from "../lib.js" + +const LANE_WIDTH = 22 +const ROW_HEIGHT = 42 +const COLORS = ["#4c8dff", "#ffb347", "#6bdc8b", "#ff6b8a", "#a883ff", "#57d4ff", "#f6d365", "#f27f57"] + +function laneColor(lane) { + return COLORS[lane % COLORS.length] +} + +function refLabels(commit) { + const labels = [] + + commit.branchLabels?.forEach(name => labels.push({ type: "branch", name })) + commit.remoteLabels?.forEach(name => labels.push({ type: "remote", name })) + commit.tags?.forEach(name => labels.push({ type: "tag", name })) + + if (commit.isHead) labels.unshift({ type: "head", name: "HEAD" }) + + return labels +} + +function renderLabels(commit) { + return refLabels(commit) + .map(ref => `${escapeHtml(ref.name)}`) + .join("") +} + +function renderSvg(graph, rowByHash) { + const width = Math.max(64, graph.lanes * LANE_WIDTH + 20) + const height = Math.max(ROW_HEIGHT, graph.commits.length * ROW_HEIGHT) + const paths = [] + const circles = [] + + graph.edges.forEach(edge => { + const fromRow = rowByHash.get(edge.from) + const toRow = rowByHash.get(edge.to) + if (fromRow == null || toRow == null) return + + const x1 = edge.fromLane * LANE_WIDTH + 12 + const y1 = fromRow * ROW_HEIGHT + ROW_HEIGHT / 2 + const x2 = edge.toLane * LANE_WIDTH + 12 + const y2 = toRow * ROW_HEIGHT + ROW_HEIGHT / 2 + const midY = y1 + Math.max(16, (y2 - y1) / 2) + + const d = x1 === x2 + ? `M ${x1} ${y1} L ${x2} ${y2}` + : `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}` + + paths.push(``) + }) + + graph.commits.forEach(commit => { + const x = commit.laneIndex * LANE_WIDTH + 12 + const y = commit.rowIndex * ROW_HEIGHT + ROW_HEIGHT / 2 + circles.push(``) + }) + + return ` + + ${paths.join("")} + ${circles.join("")} + + ` +} + +export function renderGitGraph(container, graph, onCommitClick) { + if (!graph?.commits?.length) { + container.innerHTML = `
No commits yet
` + return + } + + const rowByHash = new Map(graph.commits.map(commit => [commit.fullHash, commit.rowIndex])) + + container.innerHTML = ` +
+
+ ${renderSvg(graph, rowByHash)} +
+
+ ${graph.commits.map(commit => ` + + `).join("")} +
+
+ ` + + container.querySelectorAll(".git-graph-row").forEach(row => { + row.addEventListener("click", () => { + const commit = graph.commits.find(item => item.fullHash === row.dataset.hash) + if (commit) onCommitClick(commit) + }) + }) +} diff --git a/assets/js/git/GitPanel.js b/assets/js/git/GitPanel.js new file mode 100644 index 0000000..856153b --- /dev/null +++ b/assets/js/git/GitPanel.js @@ -0,0 +1,771 @@ +import { escapeHtml, createNotify } from "../lib.js" +import { showGitDiff } from "./GitDiffViewer.js" +import { renderGitGraph } from "./GitGraphView.js" +import { GitBlameOverlay } from "./GitBlameOverlay.js" + +const GROUPS = [ + { id: "conflicts", title: "Conflicts", icon: "warning", action: "stage", empty: "No conflicts" }, + { id: "staged", title: "Staged Changes", icon: "task_alt", action: "unstage", empty: "No staged files" }, + { id: "changes", title: "Changes", icon: "edit_square", action: "stage", empty: "No changes" }, + { id: "untracked", title: "Untracked", icon: "note_add", action: "stage", empty: "No untracked files" } +] + +const STATUS_CLASS = { + modified: "modified", + added: "added", + deleted: "deleted", + renamed: "renamed", + copied: "renamed", + untracked: "untracked", + conflicted: "conflicted", + clean: "clean" +} + +function ensure(response) { + if (!response?.success) { + throw new Error(response?.error || "Git operation failed") + } + + return response.result +} + +function notify(title, content, icon = "check") { + createNotify({ icon, title, content, time: 3500 }) +} + +function shortDate(date) { + if (!date) return "" + + try { + return new Date(date).toLocaleString() + } catch { + return date + } +} + +function statusClass(entry) { + return STATUS_CLASS[entry.type] || STATUS_CLASS[entry.label] || "modified" +} + +function statusLabel(entry) { + if (entry.label) return entry.label + if (entry.type) return entry.type + return "Modified" +} + +function fileName(filePath) { + return filePath.split(/[\\/]/).pop() +} + +function dirname(filePath) { + const parts = filePath.split(/[\\/]/) + parts.pop() + return parts.join("/") +} + +function renderButton(action, icon, title, extra = "") { + return ` + + ` +} + +class GitContextMenu { + constructor() { + this.el = document.createElement("div") + this.el.className = "context-menu git-context-menu hidden" + document.body.appendChild(this.el) + document.addEventListener("click", () => this.hide()) + document.addEventListener("keydown", event => { + if (event.key === "Escape") this.hide() + }) + } + + hide() { + this.el.classList.add("hidden") + } + + show(event, items) { + event.preventDefault() + event.stopPropagation() + + this.el.innerHTML = items.map(item => { + if (item.type === "divider") { + return `
` + } + + return ` +
+
+ ${item.icon || "radio_button_unchecked"} +
${escapeHtml(item.label)}
+
+
+ ` + }).join("") + + items.forEach(item => { + if (!item.id || item.disabled) return + this.el.querySelector(`[data-id="${item.id}"]`)?.addEventListener("click", () => { + this.hide() + item.action() + }) + }) + + this.el.classList.remove("hidden") + const edgeGap = 8 + const left = Math.min(event.clientX, window.innerWidth - this.el.offsetWidth - edgeGap) + const top = Math.min(event.clientY, window.innerHeight - this.el.offsetHeight - edgeGap) + + this.el.style.left = `${Math.max(edgeGap, left)}px` + this.el.style.top = `${Math.max(edgeGap, top)}px` + } +} + +export class GitPanel { + constructor({ pathContext }) { + this.pathContext = pathContext + this.root = document.querySelector(`.explorer-elements[data-tab="git"]`) + this.state = { + activeView: "changes", + status: null, + branches: null, + log: null, + graph: null, + selectedCommit: null, + loading: false + } + this.contextMenu = new GitContextMenu() + this.blameOverlay = new GitBlameOverlay(pathContext) + + this.bind() + this.render() + } + + get rootPath() { + return this.pathContext.rootPath + } + + bind() { + if (!this.root) return + + this.root.addEventListener("click", event => { + const actionEl = event.target.closest("[data-action]") + if (!actionEl) return + + this.handleAction(actionEl.dataset.action, actionEl) + }) + + this.root.addEventListener("change", event => { + const viewInput = event.target.closest("[name='git-panel-view']") + if (!viewInput) return + + this.state.activeView = viewInput.value + this.render() + this.ensureViewData() + }) + + this.root.addEventListener("contextmenu", event => { + const fileEl = event.target.closest(".git-file-row") + const commitEl = event.target.closest(".git-commit-row") + const branchEl = event.target.closest(".git-branch-row") + + if (fileEl) { + this.showFileMenu(event, fileEl) + return + } + if (commitEl) { + this.showCommitMenu(event, commitEl) + return + } + if (branchEl) { + this.showBranchMenu(event, branchEl) + } + }) + } + + setLoading(value, text = "Loading Git data...") { + this.state.loading = value + const loader = this.root?.querySelector(".git-panel__loading") + if (!loader) return + + loader.classList.toggle("hidden", !value) + loader.querySelector("span:last-child").textContent = text + } + + async loadInitial() { + await this.refresh() + } + + async refresh() { + if (!this.rootPath) { + this.state.status = null + this.render() + return + } + + this.setLoading(true) + try { + const [status, branches] = await Promise.all([ + window.electron.gitStatus(this.rootPath), + window.electron.gitBranches(this.rootPath) + ]) + + this.state.status = ensure(status) + this.state.branches = ensure(branches) + this.render() + await this.ensureViewData() + } catch (error) { + this.state.status = null + this.state.branches = null + this.renderError(error.message) + } finally { + this.setLoading(false) + } + } + + async ensureViewData() { + if (!this.rootPath) return + + if (this.state.activeView === "history" && !this.state.log) { + await this.loadHistory() + } + + if (this.state.activeView === "graph" && !this.state.graph) { + await this.loadGraph() + } + } + + async loadHistory(limit = 300) { + this.setLoading(true, "Loading commit history...") + try { + this.state.log = ensure(await window.electron.gitLog(this.rootPath, limit)) + this.render() + } catch (error) { + notify("Git history error", error.message, "error") + } finally { + this.setLoading(false) + } + } + + async loadGraph(limit = 400) { + this.setLoading(true, "Building commit graph...") + try { + this.state.graph = ensure(await window.electron.gitGraph(this.rootPath, limit)) + this.render() + } catch (error) { + notify("Git graph error", error.message, "error") + } finally { + this.setLoading(false) + } + } + + render() { + if (!this.root) return + + if (!this.rootPath) { + this.root.innerHTML = ` +
+
+ folder_open + Open a folder to use Git +
+
+ ` + return + } + + this.root.innerHTML = ` +
+ ${this.renderHeader()} + ${this.renderViews()} + +
+ ` + + if (this.state.activeView === "graph" && this.state.graph) { + renderGitGraph( + this.root.querySelector(".git-graph-host"), + this.state.graph, + commit => this.openCommitDiff(commit.fullHash) + ) + } + } + + renderError(message) { + this.root.innerHTML = ` +
+
+ warning + ${escapeHtml(message)} + +
+
+ ` + } + + renderHeader() { + const status = this.state.status + const branch = this.state.branches + + return ` +
+
+ account_tree + ${escapeHtml(branch?.current || status?.branch?.current || "No branch")} +
+
+ ${branch?.upstream ? `${escapeHtml(branch.upstream)}` : `No upstream`} + ↑ ${branch?.ahead ?? 0} + ↓ ${branch?.behind ?? 0} +
+
+ ${renderButton("refresh", "refresh", "Refresh")} + ${renderButton("fetch", "download", "Fetch")} + ${renderButton("pull", "south", "Pull")} + ${renderButton("push", "north", "Push current branch")} + ${renderButton("blame", "manage_search", "Toggle blame for active file")} +
+
+ ` + } + + renderViews() { + const active = this.state.activeView + + return ` +
+ + + + + + + + +
+
+ ${active === "changes" ? this.renderChanges() : ""} + ${active === "history" ? this.renderHistory() : ""} + ${active === "branches" ? this.renderBranches() : ""} + ${active === "graph" ? this.renderGraph() : ""} +
+ ` + } + + renderChanges() { + const status = this.state.status + + if (!status) { + return `
Git status is not loaded
` + } + + return ` +
+ + +
+ ${status.isClean ? ` +
+ + Working tree clean +
+ ` : ""} + ${GROUPS.map(group => this.renderGroup(group, status.groups[group.id] || [])).join("")} + ${this.renderCommitBox(status)} + ` + } + + renderGroup(group, files) { + return ` +
+
+
+ ${group.icon} + ${group.title} + ${files.length} +
+
+
+ ${files.length + ? files.map(file => this.renderFileRow(file, group.id)).join("") + : `
${group.empty}
`} +
+
+ ` + } + + renderFileRow(file, group) { + const primaryAction = group === "staged" ? "unstage" : "stage" + const primaryIcon = group === "staged" ? "remove_done" : "add_task" + const primaryTitle = group === "staged" ? "Unstage file" : "Stage file" + + return ` +
+ +
+ ${renderButton(primaryAction, primaryIcon, primaryTitle)} + ${renderButton("discard-file", "undo", "Discard changes")} +
+
+ ` + } + + renderCommitBox(status) { + const staged = status.groups.staged || [] + + return ` +
+
+ Commit + ${staged.length} staged +
+ + +
+ ` + } + + renderHistory() { + const log = this.state.log + + if (!log) { + return `` + } + + return ` +
+ +
+
+ ${log.commits.map(commit => ` +
+ +
+ ${commit.files.slice(0, 8).map(file => ` + + `).join("")} + ${commit.files.length > 8 ? `+${commit.files.length - 8} files` : ""} +
+
+ `).join("")} +
+ ` + } + + renderBranches() { + const branches = this.state.branches + + if (!branches) { + return `
Branches are not loaded
` + } + + return ` +
+ + +
+
+

Local Branches

+ ${branches.local.map(branch => this.renderBranchRow(branch)).join("") || `
No local branches
`} +
+
+

Remote Branches

+ ${branches.remote.map(branch => this.renderBranchRow(branch, true)).join("") || `
No remote branches
`} +
+ ` + } + + renderBranchRow(branch, remote = false) { + return ` +
+
+ ${branch.current ? "radio_button_checked" : "account_tree"} + + ${escapeHtml(branch.name)} + ${branch.upstream ? escapeHtml(branch.upstream) : escapeHtml(branch.hash || "")} + +
+
+ ${renderButton("checkout-branch", "login", "Checkout branch", remote ? "disabled" : "")} + ${renderButton("merge-branch", "call_merge", "Merge into current branch")} + ${renderButton("rebase-branch", "fork_right", "Rebase current branch onto this branch")} + ${remote ? "" : renderButton("rename-branch", "drive_file_rename_outline", "Rename branch")} + ${remote ? "" : renderButton("delete-branch", "delete", "Delete branch")} +
+
+ ` + } + + renderGraph() { + return ` +
+ +
+
+ ${this.state.graph ? "" : ``} +
+ ` + } + + async handleAction(action, el) { + try { + if (action === "refresh") return await this.refresh() + if (action === "stage-all") return await this.runAndRefresh(() => window.electron.gitStageAll(this.rootPath), "All files staged") + if (action === "unstage-all") return await this.runAndRefresh(() => window.electron.gitUnstageAll(this.rootPath), "All files unstaged") + if (action === "fetch") return await this.runNetworkAction("fetch") + if (action === "pull") return await this.pull() + if (action === "push") return await this.runNetworkAction("push") + if (action === "blame") return await this.blameOverlay.toggle() + if (action === "commit") return await this.commit() + if (action === "load-history") return await this.loadHistory() + if (action === "load-more-history") return await this.loadHistory((this.state.log?.limit || 300) + 200) + if (action === "load-graph" || action === "reload-graph") return await this.loadGraph() + if (action === "create-branch") return await this.createBranch() + + const fileRow = el.closest(".git-file-row") + if (fileRow) return await this.handleFileAction(action, fileRow) + + const commitRow = el.closest(".git-commit-row") + if (commitRow) return await this.handleCommitAction(action, commitRow, el) + + const branchRow = el.closest(".git-branch-row") + if (branchRow) return await this.handleBranchAction(action, branchRow) + } catch (error) { + notify("Git error", error.message, "error") + } + } + + async runAndRefresh(fn, successMessage) { + try { + this.setLoading(true) + ensure(await fn()) + notify("Git", successMessage) + this.state.log = null + this.state.graph = null + await this.refresh() + } finally { + this.setLoading(false) + } + } + + async runNetworkAction(type) { + try { + this.setLoading(true, `${type[0].toUpperCase()}${type.slice(1)} in progress...`) + const api = { + fetch: window.electron.gitFetch, + push: window.electron.gitPush + }[type] + const result = ensure(await api(this.rootPath)) + notify(`Git ${type}`, String(result || "Done")) + await this.refresh() + } finally { + this.setLoading(false) + } + } + + async pull() { + const hasChanges = ensure(await window.electron.gitHasChanges(this.rootPath)) + if (hasChanges) { + const confirmed = window.confirm("There are uncommitted changes. Pull can create conflicts. Continue?") + if (!confirmed) return + } + + try { + this.setLoading(true, "Pull in progress...") + const result = ensure(await window.electron.gitPull(this.rootPath)) + notify("Git pull", String(result || "Pull completed")) + await this.refresh() + } finally { + this.setLoading(false) + } + } + + async commit() { + const message = this.root.querySelector("#gitCommitMessage")?.value?.trim() + + if (!message) { + throw new Error("Commit message cannot be empty") + } + + try { + this.setLoading(true, "Creating commit...") + const result = ensure(await window.electron.gitCommit(this.rootPath, message)) + notify("Git commit", result.output || "Commit created") + this.state.status = result.status + this.state.log = null + this.state.graph = null + await this.refresh() + } finally { + this.setLoading(false) + } + } + + async handleFileAction(action, row) { + const filePath = row.dataset.path + const group = row.dataset.group + const untracked = row.dataset.untracked === "true" + + if (action === "diff-file") { + const diff = ensure(await window.electron.gitDiff(this.rootPath, { + path: filePath, + cached: group === "staged", + untracked + })) + showGitDiff({ title: `Diff · ${filePath}`, diff }) + return + } + + if (action === "stage") { + return await this.runAndRefresh(() => window.electron.gitStage(this.rootPath, filePath), `${filePath} staged`) + } + + if (action === "unstage") { + return await this.runAndRefresh(() => window.electron.gitUnstage(this.rootPath, filePath), `${filePath} unstaged`) + } + + if (action === "discard-file") { + const confirmed = window.confirm(`Discard changes in ${filePath}? This cannot be undone.`) + if (!confirmed) return + return await this.runAndRefresh(() => window.electron.gitDiscard(this.rootPath, filePath, { untracked }), `${filePath} restored`) + } + + if (action === "restore-file") { + const confirmed = window.confirm(`Restore ${filePath} from HEAD? Staged and unstaged changes will be lost.`) + if (!confirmed) return + return await this.runAndRefresh(() => window.electron.gitRestore(this.rootPath, filePath), `${filePath} restored`) + } + } + + async handleCommitAction(action, row, actionEl) { + const hash = row.dataset.hash + + if (action === "commit-diff") { + await this.openCommitDiff(hash) + } + + if (action === "commit-file-diff") { + const filePath = actionEl.dataset.path + const diff = ensure(await window.electron.gitCommitDiff(this.rootPath, { commit: hash, path: filePath })) + showGitDiff({ title: `${hash.slice(0, 8)} · ${filePath}`, diff }) + } + } + + async openCommitDiff(hash) { + const diff = ensure(await window.electron.gitCommitDiff(this.rootPath, { commit: hash })) + showGitDiff({ title: `Commit ${hash.slice(0, 8)}`, diff }) + } + + async handleBranchAction(action, row) { + const branch = row.dataset.branch + + if (action === "checkout-branch") { + return await this.runAndRefresh(() => window.electron.gitCheckout(this.rootPath, branch), `Checked out ${branch}`) + } + + if (action === "merge-branch") { + const confirmed = window.confirm(`Merge ${branch} into current branch?`) + if (!confirmed) return + const result = ensure(await window.electron.gitMerge(this.rootPath, branch)) + notify(result.conflicted ? "Merge conflicts" : "Merge completed", result.output || "") + await this.refresh() + } + + if (action === "rebase-branch") { + const confirmed = window.confirm(`Rebase current branch onto ${branch}?`) + if (!confirmed) return + const result = ensure(await window.electron.gitRebase(this.rootPath, branch)) + notify(result.conflicted ? "Rebase conflicts" : "Rebase completed", result.output || "") + await this.refresh() + } + + if (action === "delete-branch") { + const confirmed = window.confirm(`Delete local branch ${branch}?`) + if (!confirmed) return + return await this.runAndRefresh(() => window.electron.gitDeleteBranch(this.rootPath, branch), `${branch} deleted`) + } + + if (action === "rename-branch") { + const nextName = window.prompt("New branch name", branch) + if (!nextName || nextName === branch) return + return await this.runAndRefresh(() => window.electron.gitRenameBranch(this.rootPath, branch, nextName), `${branch} renamed`) + } + } + + async createBranch() { + const input = this.root.querySelector("#gitNewBranchName") + const branch = input?.value?.trim() + if (!branch) throw new Error("Branch name cannot be empty") + + await this.runAndRefresh(() => window.electron.gitCreateBranch(this.rootPath, branch, true), `${branch} created`) + } + + showFileMenu(event, fileEl) { + const group = fileEl.dataset.group + const filePath = fileEl.dataset.path + const untracked = fileEl.dataset.untracked === "true" + + this.contextMenu.show(event, [ + { id: "diff", label: "Open Diff", icon: "difference", action: () => this.handleFileAction("diff-file", fileEl) }, + { type: "divider" }, + group === "staged" + ? { id: "unstage", label: "Unstage File", icon: "remove_done", action: () => this.handleFileAction("unstage", fileEl) } + : { id: "stage", label: "Stage File", icon: "add_task", action: () => this.handleFileAction("stage", fileEl) }, + { id: "discard", label: untracked ? "Delete Untracked File" : "Discard Changes", icon: "undo", action: () => this.handleFileAction("discard-file", fileEl) }, + { id: "restore", label: "Restore File", icon: "settings_backup_restore", disabled: untracked, action: () => this.handleFileAction("restore-file", fileEl) }, + { type: "divider" }, + { id: "copy", label: "Copy Relative Path", icon: "content_copy", action: () => navigator.clipboard.writeText(filePath) } + ]) + } + + showCommitMenu(event, commitEl) { + const hash = commitEl.dataset.hash + + this.contextMenu.show(event, [ + { id: "diff", label: "Open Commit Diff", icon: "difference", action: () => this.openCommitDiff(hash) }, + { id: "copy", label: "Copy Commit Hash", icon: "content_copy", action: () => navigator.clipboard.writeText(hash) } + ]) + } + + showBranchMenu(event, branchEl) { + const branch = branchEl.dataset.branch + const remote = branchEl.dataset.remote === "true" + + this.contextMenu.show(event, [ + { id: "checkout", label: "Checkout", icon: "login", disabled: remote, action: () => this.handleBranchAction("checkout-branch", branchEl) }, + { id: "merge", label: "Merge Into Current", icon: "call_merge", action: () => this.handleBranchAction("merge-branch", branchEl) }, + { id: "rebase", label: "Rebase Current Onto This", icon: "fork_right", action: () => this.handleBranchAction("rebase-branch", branchEl) }, + { type: "divider" }, + { id: "rename", label: "Rename", icon: "drive_file_rename_outline", disabled: remote, action: () => this.handleBranchAction("rename-branch", branchEl) }, + { id: "delete", label: "Delete", icon: "delete", disabled: remote, action: () => this.handleBranchAction("delete-branch", branchEl) }, + { type: "divider" }, + { id: "copy", label: "Copy Branch Name", icon: "content_copy", action: () => navigator.clipboard.writeText(branch) } + ]) + } +} + +export function initGitPanel(options) { + return new GitPanel(options) +} diff --git a/html/index.html b/html/index.html index 864c489..c09c332 100644 --- a/html/index.html +++ b/html/index.html @@ -218,6 +218,9 @@ + @@ -278,6 +281,9 @@
+
+
+
@@ -344,4 +350,4 @@

$cdmtn_

- \ No newline at end of file + diff --git a/languages/en.json b/languages/en.json index 1a00ed1..47b0586 100644 --- a/languages/en.json +++ b/languages/en.json @@ -1,3 +1,170 @@ { - "name": "English" -} \ No newline at end of file + "name": "English", + + "greeting": { + "default": "How do you feel today, %{name}?", + "withoutUsername": "How do you feel today?" + }, + + "platformNotSupported": "Your platform (%{platform}) is not supported", + + "notAuth": "Not authorized", + "devModeEnabled": "Developer mode", + + "popups": { + "file": { + "title": "File" + }, + "tools": { + "title": "Tools", + "appearance": "Appearance" + }, + "actions": { + "title": "Actions", + "maximize": "Maximize application", + "minimize": "Minimize application", + "reload": "Reload application", + "close": "Close application" + }, + "account": { + "title": "Me", + "installedExtensions": "Installed extensions", + "yourOrganizations": "Organizations", + "createOrganization": "Create organization", + "login": "Login", + "logout": "Logout" + } + }, + + "tooltips": { + "files": "Files", + "history": "History", + "bugs": "Bugs", + "git": "Git", + "pythonEmulator": "Python emulator", + "mdPreview": "Markdown preview", + "htmlLiveServer": "Live Server", + "errors": "Errors", + + "codeSnippet": "Take screenshot", + "copy": "Copy", + "terminal": "Terminal", + "jsMinify": "Minify JS", + "cssMinify": "Minify CSS", + "organizations": "Organizations" + }, + + "editor": { + "nocontext": "No context" + }, + + "explorer": { + "emptyBeforeIcon": "Click", + "emptyAfterIcon": "to open a folder" + }, + + "bugs": { + "segment": { + "all": "All", + "local": "Local", + "done": "Done" + } + }, + + "modals": { + "needToReloadNote": "Restart the application after changing this setting", + "appReloadNote": "Changing this setting restarts the application", + "organizations": { + "title": "Organizations", + "membersLabel": "Members", + "roleLabel": "Role", + "ownerLabel": "You are the owner of this organization", + "secondRoleLabel": "Your second role: %{role}", + "ownerRoleLabel": "Owner" + }, + "appearance": { + "title": "{{popups.tools.appearance}}", + "generalCategory": "General", + "sideBarCategory": "Sidebar", + "terminalCategory": "Terminal", + "fileWindowCategory": "File window", + "editorCategory": "Editor", + "application": { + "applicationLabel": "Application", + "uiScale": { + "title": "UI scale", + "description": "Changes the global UI scale" + }, + "language": { + "title": "Language", + "description": "Choose the default application language" + }, + "useSystemFonts": { + "title": "Use system fonts", + "description": "Use system-ui for the interface and monospace for the editor" + }, + "splashWindow": { + "title": "Splash screen", + "description": "Show the splash screen on startup" + }, + "reduceMotion": { + "title": "Reduce motion", + "description": "Reduces visual effects across the application" + }, + "boldFont": { + "title": "Bold font", + "description": "Increases font weight across the application" + }, + "theme": { + "title": "Theme", + "description": "Change application theme" + }, + "developerMode": { + "title": "Developer mode", + "description": "Enable developer tools and debugger window" + }, + "appIcons": { + "title": "Application icons", + "description": "Changes the app icon" + } + }, + "editor": { + "editorLabel": "Editor", + "builtInPythonCausePlatformNote": "{{platformNotSupported}}. Built-in Python is used", + "textSize": { + "title": "Font size", + "description": "Increase editor text up to 200%" + }, + "smoothScroll": { + "title": "Smooth scroll", + "description": "Enable smooth editor scrolling" + }, + "pythonRunner": { + "title": "Python runner", + "description": "Choose which Python runtime to use", + "select": { + "builtIn": "Built into IDE", + "userDefined": "User installed" + } + } + } + }, + "addBug": { + "title": "Add bug", + "header": { + "title": "Add bug information", + "description": "Fill in all fields before sending a bug report" + }, + "inputs": { + "name": "Name", + "description": "Description" + }, + "localBugSwitch": { + "title": "Add as local bug", + "description": "Only you will see this bug. It will be stored locally" + }, + "confirmBtn": "Confirm and add global bug", + "confirmBtnLocal": "Confirm and add local bug" + } + } +} diff --git a/languages/ru.json b/languages/ru.json index f7288ee..d59a5a6 100644 --- a/languages/ru.json +++ b/languages/ru.json @@ -40,6 +40,7 @@ "files": "Файлы", "history": "История", "bugs": "Баги", + "git": "Git", "pythonEmulator": "Python эмулятор", "mdPreview": "MD Превью", "htmlLiveServer": "Live-Server", @@ -166,4 +167,4 @@ "confirmBtnLocal": "Подтвердить и добавить локальный баг" } } -} \ No newline at end of file +} diff --git a/main.js b/main.js index 41345f7..fac12ef 100644 --- a/main.js +++ b/main.js @@ -39,6 +39,7 @@ require("./helpers/getPython.js") require("./app/auth.js") require("./app/electron/live-server.js") require("./app/runtime/runtimeHandler.js") +require("./app/git/gitIpc.js").registerGitIpc() const { terminalManager } = require("./app/helpers/terminal.js") const { createDebuggerWindow } = require("./helpers/debuggerWindow/debuggerWindow.js"); From 5baeabf128b3f1b3a4626c1e6375623c88c1711c Mon Sep 17 00:00:00 2001 From: matt00ff Date: Mon, 18 May 2026 02:39:08 +0300 Subject: [PATCH 2/2] Fix CodeMotion IDE: Style of Git-panel buttons Changes/History/Branches/Graph --- assets/css/components/git.css | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/assets/css/components/git.css b/assets/css/components/git.css index 1fe6004..b53eb1e 100644 --- a/assets/css/components/git.css +++ b/assets/css/components/git.css @@ -117,30 +117,38 @@ background: transparent; display: grid; flex: 0 0 auto; - gap: 4px; + gap: 6px; grid-template-columns: repeat(auto-fit, minmax(76px, 1fr)); margin-bottom: 0; overflow: visible; - padding: 0; + padding: 0 6px; + box-sizing: border-box; + width: 100%; } .git-view-switch>label { background: var(--block-divider-border-color); - border: 1px solid transparent; + border: 1px solid var(--topbar-menu-item-hover-bg); border-radius: 6px; box-sizing: border-box; + height: 42px; + justify-content: center; min-width: 0; - padding: 8px 6px; + 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: var(--topbar-menu-item-hover-bg); + border-color: rgba(255, 255, 255, .26); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .04); } .git-view-switch label span { @@ -151,6 +159,10 @@ white-space: nowrap; } +.git-view-switch>label:hover { + background: var(--topbar-menu-item-hover-bg); +} + .git-view { display: flex; flex-direction: column;