diff --git a/package-lock.json b/package-lock.json index a637b9a..8b40e16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@lucide/svelte": "^1.21.0", "@tailwindcss/vite": "^4.3.1", "@tauri-apps/api": "^2.5.0", + "simple-icons": "^16.24.1", "svelte": "^5.0.0", "tailwindcss": "^4.3.1" }, @@ -2106,6 +2107,25 @@ "node": ">=6" } }, + "node_modules/simple-icons": { + "version": "16.24.1", + "resolved": "https://registry.npmjs.org/simple-icons/-/simple-icons-16.24.1.tgz", + "integrity": "sha512-AnDQPrZAVzYSym7cBVIrnbhLk9auWGgkl+9hKvkbTqGEfH6TU7WKgumEXYYaJuM1Ib87+cnzr881UZniCM7t+A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/simple-icons" + }, + { + "type": "github", + "url": "https://github.com/sponsors/simple-icons" + } + ], + "license": "CC0-1.0", + "engines": { + "node": ">=0.12.18" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/package.json b/package.json index a882301..4960ce8 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@lucide/svelte": "^1.21.0", "@tailwindcss/vite": "^4.3.1", "@tauri-apps/api": "^2.5.0", + "simple-icons": "^16.24.1", "svelte": "^5.0.0", "tailwindcss": "^4.3.1" }, diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index e8dca9c..7947b3c 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -117,6 +117,8 @@ enum CheckoutPlan { Raw(String), } +const FULL_FILE_DIFF_CONTEXT: &str = "--unified=1000000"; + #[tauri::command] pub fn open_repository(path: String) -> Result { let repo = resolve_repo(&path)?; @@ -247,6 +249,13 @@ pub fn commit(path: String, message: String) -> Result { return Err("Commit-Message darf nicht leer sein.".to_string()); } + let current_status = status_for_repo(&repo)?; + if has_unresolved_conflicts(¤t_status) { + return Err( + "Merge-Konflikte muessen geloest werden, bevor du committen kannst.".to_string(), + ); + } + run_git(&repo, ["commit", "-m", message.as_str()])?; status_for_repo(&repo) } @@ -310,10 +319,7 @@ pub fn merge_branch(path: String, branch: String) -> Result { // Surface those through the status so the UI can offer conflict resolution // instead of treating the conflict as a hard error. let status = status_for_repo(&repo)?; - if status.files.iter().any(|file| { - matches!(file.staged, Some(FileStatusKind::Conflicted)) - || matches!(file.unstaged, Some(FileStatusKind::Conflicted)) - }) { + if has_unresolved_conflicts(&status) { return Ok(status); } @@ -447,7 +453,16 @@ pub fn compare_commits( to_hash.as_str(), ], )?; - let patch_output = run_git(&repo, ["diff", "-M", from_hash.as_str(), to_hash.as_str()])?; + let patch_output = run_git( + &repo, + [ + "diff", + "-M", + FULL_FILE_DIFF_CONTEXT, + from_hash.as_str(), + to_hash.as_str(), + ], + )?; let files = parse_diff_files(&name_status, &numstat)?; let patch = String::from_utf8_lossy(&patch_output).to_string(); @@ -484,7 +499,7 @@ pub fn diff_file_against_working_tree( )?; let patch_output = run_git_with_paths( &repo, - &["diff", "-M", commit_hash.as_str()], + &["diff", "-M", FULL_FILE_DIFF_CONTEXT, commit_hash.as_str()], std::slice::from_ref(&file), )?; @@ -748,6 +763,13 @@ fn status_for_file(statuses: &[GitFileStatus], path: &str) -> Option bool { + status.files.iter().any(|file| { + matches!(file.staged, Some(FileStatusKind::Conflicted)) + || matches!(file.unstaged, Some(FileStatusKind::Conflicted)) + }) +} + fn parse_commit_log(repo: &Path, output: &[u8]) -> Result, String> { const FIELD_SEPARATOR: char = '\x1f'; const RECORD_SEPARATOR: char = '\x1e'; @@ -1713,7 +1735,8 @@ mod tests { fs::write(repo.path.join("old.txt"), "original\nsecond line\n") .expect("tracked file should change"); - fs::write(repo.path.join("added.txt"), "brand new\n").expect("added file should be written"); + fs::write(repo.path.join("added.txt"), "brand new\n") + .expect("added file should be written"); run_git_test(&repo.path, ["add", "old.txt", "added.txt"]); run_git_test(&repo.path, ["commit", "-q", "-m", "second"]); let second_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]); @@ -1736,6 +1759,40 @@ mod tests { assert!(comparison.patch.contains("second line")); } + #[test] + fn compare_commits_includes_full_file_context() { + let repo = init_temp_repo("compare_full_context"); + let before = (1..=60) + .map(|line| format!("line {line}\n")) + .collect::(); + + fs::write(repo.path.join("context.txt"), &before).expect("context file should be written"); + run_git_test(&repo.path, ["add", "context.txt"]); + run_git_test(&repo.path, ["commit", "-q", "-m", "base"]); + let first_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]); + + fs::write( + repo.path.join("context.txt"), + before.replace("line 30\n", "line 30 changed\n"), + ) + .expect("context file should be changed"); + run_git_test(&repo.path, ["add", "context.txt"]); + run_git_test(&repo.path, ["commit", "-q", "-m", "change"]); + let second_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]); + + let comparison = compare_commits( + repo.path.to_string_lossy().to_string(), + first_commit, + second_commit, + ) + .unwrap(); + + assert!(comparison.patch.contains(" line 1\n")); + assert!(comparison.patch.contains(" line 60\n")); + assert!(comparison.patch.contains("-line 30\n")); + assert!(comparison.patch.contains("+line 30 changed\n")); + } + #[test] fn diff_file_against_working_tree_reports_uncommitted_changes() { let repo = init_temp_repo("diff_against_working_tree"); @@ -1786,8 +1843,11 @@ mod tests { .output() .expect("git merge should start"); - let conflict = - read_conflict(repo.path.to_string_lossy().to_string(), "file.txt".to_string()).unwrap(); + let conflict = read_conflict( + repo.path.to_string_lossy().to_string(), + "file.txt".to_string(), + ) + .unwrap(); assert_eq!( conflict.ours.unwrap().replace("\r\n", "\n"), "ours change\n" @@ -1813,6 +1873,40 @@ mod tests { assert_eq!(contents.replace("\r\n", "\n"), "resolved\n"); } + #[test] + fn commit_rejects_unresolved_merge_conflicts() { + let repo = init_temp_repo("commit_rejects_conflicts"); + fs::write(repo.path.join("file.txt"), "base\n").expect("base file should be written"); + run_git_test(&repo.path, ["add", "file.txt"]); + run_git_test(&repo.path, ["commit", "-q", "-m", "base"]); + let main_branch = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]); + + run_git_test(&repo.path, ["checkout", "-q", "-b", "feature"]); + fs::write(repo.path.join("file.txt"), "theirs change\n").expect("feature change"); + run_git_test(&repo.path, ["commit", "-q", "-am", "feature change"]); + + run_git_test(&repo.path, ["checkout", "-q", main_branch.as_str()]); + fs::write(repo.path.join("file.txt"), "ours change\n").expect("main change"); + run_git_test(&repo.path, ["commit", "-q", "-am", "main change"]); + + let _ = Command::new("git") + .arg("-C") + .arg(&repo.path) + .args(["merge", "--no-edit", "feature"]) + .output() + .expect("git merge should start"); + + let err = commit( + repo.path.to_string_lossy().to_string(), + "should not commit".to_string(), + ) + .unwrap_err(); + + assert!(err.contains("Merge-Konflikte")); + let status = status_for_repo(&repo.path).unwrap(); + assert!(has_unresolved_conflicts(&status)); + } + #[test] fn detects_binary_content_by_nul_byte() { assert!(is_binary_bytes(&[0u8, 1, 2, 3])); @@ -1822,7 +1916,8 @@ mod tests { #[test] fn binary_conflict_can_be_resolved_by_side() { let repo = init_temp_repo("binary_conflict"); - fs::write(repo.path.join("img.bin"), [0u8, 1, 2, 3]).expect("base binary should be written"); + fs::write(repo.path.join("img.bin"), [0u8, 1, 2, 3]) + .expect("base binary should be written"); run_git_test(&repo.path, ["add", "img.bin"]); run_git_test(&repo.path, ["commit", "-q", "-m", "base"]); let main_branch = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]); @@ -1842,8 +1937,11 @@ mod tests { .output() .expect("git merge should start"); - let conflict = - read_conflict(repo.path.to_string_lossy().to_string(), "img.bin".to_string()).unwrap(); + let conflict = read_conflict( + repo.path.to_string_lossy().to_string(), + "img.bin".to_string(), + ) + .unwrap(); assert!(conflict.binary); assert!(conflict.content.is_empty()); assert_eq!(conflict.ours_size, Some(3)); diff --git a/src/App.svelte b/src/App.svelte index 86236d1..7337991 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -95,10 +95,13 @@ $: changedFiles = status?.files ?? []; $: stagedCount = status?.files.filter((f) => f.staged !== null).length ?? 0; $: unstagedCount = status?.files.filter((f) => f.unstaged !== null).length ?? 0; - $: canCommit = hasRepository && stagedCount > 0 && commitMessage.trim().length > 0 && !isBusy; - $: canCompare = hasRepository && compareFrom.length > 0 && compareTo.length > 0 && compareFrom !== compareTo && !isBusy; $: conflictedFiles = changedFiles.filter((f) => f.staged === "conflicted" || f.unstaged === "conflicted"); $: hasConflicts = conflictedFiles.length > 0; + $: canCommit = hasRepository && stagedCount > 0 && commitMessage.trim().length > 0 && !hasConflicts && !isBusy; + $: commitBlockReason = hasConflicts + ? `${conflictedFiles.length} ${conflictedFiles.length === 1 ? "merge conflict must" : "merge conflicts must"} be resolved before committing.` + : ""; + $: canCompare = hasRepository && compareFrom.length > 0 && compareTo.length > 0 && compareFrom !== compareTo && !isBusy; $: localBranches = branches.filter((b) => !b.remote); $: remoteBranches = branches.filter((b) => b.remote); @@ -371,6 +374,10 @@ async function commitChanges() { const message = commitMessage.trim(); if (!message || !activeRepoPath) return; + if (hasConflicts) { + errorMessage = "Resolve all merge conflicts before committing."; + return; + } await runOperation("Committing", async () => { applyStatus(await commit(activeRepoPath, message)); commitMessage = ""; @@ -670,6 +677,7 @@ - + + + + + + {/each} + + {/if} + +
+
syncResolveScroll("before")} + > +
+ {#each resolveSplitRows as row, i (`left-${i}`)} + {#if row.type === "marker"} +
Conflict {row.conflictIndex + 1}
+ {:else} +
{row.leftNum ?? ""}
+
{displayLine(row.leftText ?? "") || " "}
+ {/if} + {/each} +
+
+
syncResolveScroll("after")} + > +
+ {#each resolveSplitRows as row, i (`right-${i}`)} + {#if row.type === "marker"} +
Conflict {row.conflictIndex + 1}
+ {:else} +
{row.rightNum ?? ""}
+
{displayLine(row.rightText ?? "") || " "}
+ {/if} + {/each} +
+
+
+ + {#if false} + {#each legacyConflictParts() as part, partIndex (partIndex)} {#if part.kind === "text"} {#if part.lines.length > 0}
{#each part.lines as line}{displayLine(line) || " "}{/each}
@@ -323,6 +470,7 @@ {/if} {/each} + {/if} {/if} diff --git a/src/lib/languageIcons.ts b/src/lib/languageIcons.ts new file mode 100644 index 0000000..32ac734 --- /dev/null +++ b/src/lib/languageIcons.ts @@ -0,0 +1,207 @@ +import type { SimpleIcon } from "simple-icons"; +import { + siAstro, + siBun, + siC, + siClojure, + siCmake, + siCplusplus, + siCss, + siDart, + siDeno, + siDocker, + siDotnet, + siEditorconfig, + siElixir, + siErlang, + siEslint, + siFortran, + siFsharp, + siGit, + siGitignoredotio, + siGnubash, + siGo, + siGradle, + siGraphql, + siHaskell, + siHtml5, + siJavascript, + siJson, + siJulia, + siKotlin, + siLua, + siMake, + siMarkdown, + siNodedotjs, + siNpm, + siOcaml, + siOpenjdk, + siPerl, + siPhp, + siPnpm, + siPrettier, + siPython, + siR, + siReact, + siRuby, + siRust, + siSass, + siScala, + siShell, + siSqlite, + siSvelte, + siSvg, + siSwift, + siTailwindcss, + siTauri, + siTerraform, + siToml, + siTypescript, + siVite, + siVuedotjs, + siYaml, + siYarn, + siZig, + siZsh, +} from "simple-icons"; + +export interface LanguageIconSpec { + icon: SimpleIcon; + title: string; +} + +function spec(icon: SimpleIcon, title = icon.title): LanguageIconSpec { + return { icon, title }; +} + +const fileNameIcons = new Map([ + ["dockerfile", spec(siDocker, "Docker")], + ["docker-compose.yml", spec(siDocker, "Docker Compose")], + ["docker-compose.yaml", spec(siDocker, "Docker Compose")], + ["compose.yml", spec(siDocker, "Docker Compose")], + ["compose.yaml", spec(siDocker, "Docker Compose")], + ["package.json", spec(siNodedotjs, "Node package")], + ["package-lock.json", spec(siNpm, "npm lockfile")], + ["pnpm-lock.yaml", spec(siPnpm, "pnpm lockfile")], + ["yarn.lock", spec(siYarn, "Yarn lockfile")], + ["bun.lockb", spec(siBun, "Bun lockfile")], + ["deno.json", spec(siDeno, "Deno")], + ["deno.jsonc", spec(siDeno, "Deno")], + ["cargo.toml", spec(siRust, "Cargo")], + ["cargo.lock", spec(siRust, "Cargo lockfile")], + ["tauri.conf.json", spec(siTauri, "Tauri")], + ["svelte.config.js", spec(siSvelte, "Svelte config")], + ["svelte.config.ts", spec(siSvelte, "Svelte config")], + ["vite.config.js", spec(siVite, "Vite config")], + ["vite.config.ts", spec(siVite, "Vite config")], + ["tailwind.config.js", spec(siTailwindcss, "Tailwind CSS config")], + ["tailwind.config.ts", spec(siTailwindcss, "Tailwind CSS config")], + ["eslint.config.js", spec(siEslint, "ESLint config")], + ["eslint.config.mjs", spec(siEslint, "ESLint config")], + [".eslintrc", spec(siEslint, "ESLint config")], + [".eslintrc.js", spec(siEslint, "ESLint config")], + [".eslintrc.cjs", spec(siEslint, "ESLint config")], + [".prettierrc", spec(siPrettier, "Prettier config")], + [".prettierrc.json", spec(siPrettier, "Prettier config")], + [".prettierrc.js", spec(siPrettier, "Prettier config")], + [".gitignore", spec(siGitignoredotio, "gitignore")], + [".gitattributes", spec(siGit, "Git attributes")], + [".gitmodules", spec(siGit, "Git modules")], + [".editorconfig", spec(siEditorconfig, "EditorConfig")], + ["cmakelists.txt", spec(siCmake, "CMake")], + ["makefile", spec(siMake, "Makefile")], + ["justfile", spec(siShell, "Justfile")], + ["rakefile", spec(siRuby, "Rakefile")], + ["gemfile", spec(siRuby, "Gemfile")], +]); + +const extensionIcons = new Map([ + ["js", spec(siJavascript)], + ["mjs", spec(siJavascript)], + ["cjs", spec(siJavascript)], + ["jsx", spec(siReact, "React JSX")], + ["ts", spec(siTypescript)], + ["mts", spec(siTypescript)], + ["cts", spec(siTypescript)], + ["tsx", spec(siReact, "React TSX")], + ["svelte", spec(siSvelte)], + ["vue", spec(siVuedotjs, "Vue")], + ["astro", spec(siAstro)], + ["rs", spec(siRust)], + ["go", spec(siGo)], + ["py", spec(siPython)], + ["pyw", spec(siPython)], + ["java", spec(siOpenjdk, "Java")], + ["kt", spec(siKotlin)], + ["kts", spec(siKotlin)], + ["cs", spec(siDotnet, "C#")], + ["cpp", spec(siCplusplus, "C++")], + ["cxx", spec(siCplusplus, "C++")], + ["cc", spec(siCplusplus, "C++")], + ["hpp", spec(siCplusplus, "C++")], + ["hh", spec(siCplusplus, "C++")], + ["c", spec(siC)], + ["h", spec(siC)], + ["swift", spec(siSwift)], + ["php", spec(siPhp, "PHP")], + ["rb", spec(siRuby)], + ["lua", spec(siLua)], + ["dart", spec(siDart)], + ["scala", spec(siScala)], + ["zig", spec(siZig)], + ["ex", spec(siElixir)], + ["exs", spec(siElixir)], + ["erl", spec(siErlang)], + ["hrl", spec(siErlang)], + ["fs", spec(siFsharp, "F#")], + ["fsx", spec(siFsharp, "F#")], + ["fsi", spec(siFsharp, "F#")], + ["clj", spec(siClojure)], + ["cljs", spec(siClojure)], + ["hs", spec(siHaskell)], + ["lhs", spec(siHaskell)], + ["ml", spec(siOcaml, "OCaml")], + ["mli", spec(siOcaml, "OCaml")], + ["jl", spec(siJulia)], + ["r", spec(siR, "R")], + ["pl", spec(siPerl)], + ["pm", spec(siPerl)], + ["f", spec(siFortran)], + ["f90", spec(siFortran)], + ["f95", spec(siFortran)], + ["html", spec(siHtml5, "HTML")], + ["htm", spec(siHtml5, "HTML")], + ["css", spec(siCss, "CSS")], + ["scss", spec(siSass, "Sass")], + ["sass", spec(siSass, "Sass")], + ["svg", spec(siSvg, "SVG")], + ["json", spec(siJson, "JSON")], + ["jsonc", spec(siJson, "JSONC")], + ["json5", spec(siJson, "JSON5")], + ["yaml", spec(siYaml, "YAML")], + ["yml", spec(siYaml, "YAML")], + ["toml", spec(siToml, "TOML")], + ["tf", spec(siTerraform, "Terraform")], + ["tfvars", spec(siTerraform, "Terraform variables")], + ["graphql", spec(siGraphql, "GraphQL")], + ["gql", spec(siGraphql, "GraphQL")], + ["md", spec(siMarkdown, "Markdown")], + ["mdx", spec(siMarkdown, "MDX")], + ["sh", spec(siGnubash, "Shell script")], + ["bash", spec(siGnubash, "Bash")], + ["zsh", spec(siZsh, "Zsh")], + ["fish", spec(siShell, "Fish shell")], + ["sql", spec(siSqlite, "SQL")], + ["sqlite", spec(siSqlite, "SQLite")], + ["sqlite3", spec(siSqlite, "SQLite")], +]); + +export function languageIconForPath(path: string): LanguageIconSpec | null { + const name = path.split(/[\\/]/).pop()?.toLowerCase() ?? ""; + const byName = fileNameIcons.get(name); + if (byName) return byName; + + const index = name.lastIndexOf("."); + if (index <= 0) return null; + return extensionIcons.get(name.slice(index + 1)) ?? null; +} diff --git a/tsconfig.node.json b/tsconfig.node.json index 5957623..8f3d35a 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -3,6 +3,7 @@ "composite": true, "module": "ESNext", "moduleResolution": "bundler", + "skipLibCheck": true, "strict": true, "target": "ES2020", "verbatimModuleSyntax": true