diff --git a/CHANGELOG.md b/CHANGELOG.md index c582e2c..d0c5145 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,11 @@ The project uses calendar-style versions in the form `YYYY.M.PATCH`. existing file actions. - Files and folders in the Changes panel now have context-menu actions for staging or unstaging their scope and for creating a stash containing only - the selected file or folder. + the selected file or folder. New and untracked items can also be added to + the repository `.gitignore` from Changes or the File Explorer as an exact + file, a complete folder, or an extension-wide pattern. Folder rules are only + offered for folder selections. Tracked files and folders can be removed from + the Git index without deleting their working-tree contents. - Gitty can open a repository directly at startup through the `--repo PATH` or `--repo=PATH` command-line argument. diff --git a/docs/api-contract.md b/docs/api-contract.md index b9dfa71..fe103d0 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -137,6 +137,8 @@ The command list below includes the repository-management and synchronization AP - `repair_worktree(path: string, worktreePath: string): Promise` - `stage_files(path: string, files: string[]): Promise` - `unstage_files(path: string, files: string[]): Promise` +- `add_to_gitignore(path: string, target: string, kind: "file" | "extension" | "folder"): Promise`; appends a repository-root `.gitignore` rule and unstages newly-added matching files. +- `untrack_paths(path: string, targets: string[]): Promise`; removes files or folders from the Git index while preserving their working-tree contents. - `restore_files(path: string, files: string[], staged: boolean): Promise` - `stash_push(path: string, message?: string, includeUntracked?: boolean, paths?: string[]): Promise`; when `paths` is provided, only matching files are stashed. - `commit(path: string, message: string): Promise` diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 029f232..72ad6f2 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -4,6 +4,7 @@ use std::{ env, ffi::{OsStr, OsString}, fs, + io::Write, path::{Path, PathBuf}, process::{Command, Output, Stdio}, sync::{ @@ -32,6 +33,14 @@ pub enum FileStatusKind { Unknown, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum GitIgnoreKind { + File, + Extension, + Folder, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct GitFileStatus { pub path: String, @@ -2029,6 +2038,30 @@ pub async fn unstage_files(path: String, files: Vec) -> Result Result { + tauri::async_runtime::spawn_blocking(move || -> Result { + let repo = resolve_repo(&path)?; + add_to_gitignore_for_repo(&repo, &target, kind) + }) + .await + .map_err(|err| format!("Could not update .gitignore: {err}"))? +} + +#[tauri::command] +pub async fn untrack_paths(path: String, targets: Vec) -> Result { + tauri::async_runtime::spawn_blocking(move || -> Result { + let repo = resolve_repo(&path)?; + untrack_paths_for_repo(&repo, &targets) + }) + .await + .map_err(|err| format!("Could not stop tracking paths: {err}"))? +} + #[tauri::command] pub async fn restore_files( path: String, @@ -6308,6 +6341,164 @@ fn validate_files(files: &[String]) -> Result<(), String> { Ok(()) } +fn normalize_gitignore_target(target: &str) -> Result { + if target.is_empty() || target.trim().is_empty() { + return Err("Ignore target must not be empty.".to_string()); + } + if target + .chars() + .any(|character| matches!(character, '\0' | '\r' | '\n')) + { + return Err("Ignore target contains an unsupported control character.".to_string()); + } + + let normalized = target.replace('\\', "/"); + if normalized.starts_with('/') || normalized.as_bytes().get(1) == Some(&b':') { + return Err("Ignore target must be relative to the repository.".to_string()); + } + + let segments: Vec<&str> = normalized.trim_end_matches('/').split('/').collect(); + if segments.is_empty() + || segments + .iter() + .any(|segment| segment.is_empty() || *segment == "." || *segment == "..") + { + return Err("Ignore target must be a normalized repository path.".to_string()); + } + if segments[0].eq_ignore_ascii_case(".git") { + return Err("The repository metadata directory cannot be ignored.".to_string()); + } + + Ok(segments.join("/")) +} + +fn escape_gitignore_literal(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for character in value.chars() { + if matches!( + character, + '\\' | '*' | '?' | '[' | ']' | '#' | '!' | ' ' | '\t' + ) { + escaped.push('\\'); + } + escaped.push(character); + } + escaped +} + +fn gitignore_extension(target: &str) -> Option<&str> { + let name = target.rsplit('/').next()?; + let separator = name.rfind('.')?; + (separator > 0 && separator < name.len() - 1).then(|| &name[separator + 1..]) +} + +fn gitignore_pattern(target: &str, kind: GitIgnoreKind) -> Result { + match kind { + GitIgnoreKind::File => Ok(format!("/{}", escape_gitignore_literal(target))), + GitIgnoreKind::Folder => Ok(format!("/{}/", escape_gitignore_literal(target))), + GitIgnoreKind::Extension => { + let extension = gitignore_extension(target) + .ok_or_else(|| "The selected file has no extension to ignore.".to_string())?; + Ok(format!("*.{}", escape_gitignore_literal(extension))) + } + } +} + +fn gitignore_target_matches(kind: GitIgnoreKind, target: &str, candidate: &str) -> bool { + match kind { + GitIgnoreKind::File => candidate == target, + GitIgnoreKind::Folder => candidate + .strip_prefix(target) + .is_some_and(|remainder| remainder.starts_with('/')), + GitIgnoreKind::Extension => gitignore_extension(target) + .zip(gitignore_extension(candidate)) + .is_some_and(|(selected, current)| selected == current), + } +} + +fn append_gitignore_pattern(repo: &Path, pattern: &str) -> Result { + let gitignore = repo.join(".gitignore"); + match fs::symlink_metadata(&gitignore) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err("Refusing to update a symlinked .gitignore file.".to_string()); + } + Ok(metadata) if metadata.is_dir() => { + return Err(".gitignore is a directory, not a file.".to_string()); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(format!("Could not inspect .gitignore: {error}")), + } + + let existing = match fs::read(&gitignore) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(), + Err(error) => return Err(format!("Could not read .gitignore: {error}")), + }; + let text = std::str::from_utf8(&existing) + .map_err(|_| ".gitignore is not valid UTF-8 and cannot be updated safely.".to_string())?; + if text + .lines() + .any(|line| line.trim_end_matches('\r') == pattern) + { + return Ok(false); + } + + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&gitignore) + .map_err(|error| format!("Could not open .gitignore: {error}"))?; + if !existing.is_empty() && !existing.ends_with(b"\n") { + file.write_all(b"\n") + .map_err(|error| format!("Could not update .gitignore: {error}"))?; + } + file.write_all(pattern.as_bytes()) + .and_then(|_| file.write_all(b"\n")) + .map_err(|error| format!("Could not update .gitignore: {error}"))?; + Ok(true) +} + +fn add_to_gitignore_for_repo( + repo: &Path, + target: &str, + kind: GitIgnoreKind, +) -> Result { + let target = normalize_gitignore_target(target)?; + let pattern = gitignore_pattern(&target, kind)?; + let status = status_for_repo(repo)?; + let staged_additions: Vec = status + .files + .iter() + .filter(|file| { + file.staged == Some(FileStatusKind::Added) + && file.old_path.is_none() + && gitignore_target_matches(kind, &target, &file.path) + }) + .map(|file| file.path.clone()) + .collect(); + + append_gitignore_pattern(repo, &pattern)?; + if !staged_additions.is_empty() { + unstage_selected_files(repo, &status.files, &staged_additions)?; + } + status_for_repo(repo) +} + +fn untrack_paths_for_repo(repo: &Path, targets: &[String]) -> Result { + validate_files(targets)?; + if !targets.is_empty() { + // --cached keeps every working-tree file in place. Force only permits + // index removal when the staged/worktree content differs from HEAD. + run_git_with_paths( + repo, + &["rm", "-r", "--cached", "--force", "--ignore-unmatch"], + targets, + )?; + } + status_for_repo(repo) +} + fn write_temp_patch(patch: &str) -> Result { let counter = CANCELLABLE_GIT_OUTPUT_COUNTER.fetch_add(1, Ordering::Relaxed); let path = std::env::temp_dir().join(format!( @@ -8970,4 +9161,121 @@ mod tests { let duplicate = r#"{"summary":"Bad plan","groups":[{"message":"feat: one","reason":"","files":["src/app.ts"]},{"message":"test: two","reason":"","files":["src/app.ts"]}]}"#; assert!(parse_ai_commit_plan(duplicate, &files).is_err()); } + + #[test] + fn gitignore_file_rule_escapes_literal_path_characters() { + let repo = init_temp_repo("gitignore_literal_path"); + fs::create_dir_all(repo.path.join("generated files")) + .expect("test directory should be created"); + let target = "generated files/[draft]!.log"; + fs::write(repo.path.join(target), "temporary\n").expect("test file should be written"); + + add_to_gitignore_for_repo(&repo.path, target, GitIgnoreKind::File) + .expect("file should be ignored"); + + assert_eq!( + fs::read_to_string(repo.path.join(".gitignore")).expect(".gitignore should exist"), + "/generated\\ files/\\[draft\\]\\!.log\n" + ); + assert_eq!( + git_output_test(&repo.path, ["check-ignore", target]), + target + ); + } + + #[test] + fn gitignore_extension_rule_unstages_all_matching_new_files_without_duplicates() { + let repo = init_temp_repo("gitignore_extension"); + fs::create_dir_all(repo.path.join("build")).expect("build directory should be created"); + fs::write(repo.path.join("build/result.log"), "result\n") + .expect("nested log should be written"); + fs::write(repo.path.join("debug.log"), "debug\n").expect("root log should be written"); + run_git_test(&repo.path, ["add", "build/result.log", "debug.log"]); + + let status = + add_to_gitignore_for_repo(&repo.path, "build/result.log", GitIgnoreKind::Extension) + .expect("extension should be ignored"); + + assert_eq!( + fs::read_to_string(repo.path.join(".gitignore")).expect(".gitignore should exist"), + "*.log\n" + ); + assert!(status.files.iter().all(|file| !file.path.ends_with(".log"))); + assert!(git_output_test(&repo.path, ["ls-files"]).is_empty()); + assert_eq!( + git_output_test( + &repo.path, + ["check-ignore", "build/result.log", "debug.log"] + ), + "build/result.log\ndebug.log" + ); + + add_to_gitignore_for_repo(&repo.path, "build/result.log", GitIgnoreKind::Extension) + .expect("existing rule should be accepted"); + assert_eq!( + fs::read_to_string(repo.path.join(".gitignore")).expect(".gitignore should exist"), + "*.log\n" + ); + } + + #[test] + fn gitignore_folder_rule_only_unstages_files_below_the_selected_folder() { + let repo = init_temp_repo("gitignore_folder"); + fs::create_dir_all(repo.path.join("cache/nested")) + .expect("cache directory should be created"); + fs::create_dir_all(repo.path.join("cache-old")) + .expect("similarly named directory should be created"); + fs::write(repo.path.join("cache/nested/result.tmp"), "cached\n") + .expect("cached file should be written"); + fs::write(repo.path.join("cache-old/keep.tmp"), "keep\n") + .expect("kept file should be written"); + run_git_test( + &repo.path, + ["add", "cache/nested/result.tmp", "cache-old/keep.tmp"], + ); + + add_to_gitignore_for_repo(&repo.path, "cache", GitIgnoreKind::Folder) + .expect("folder should be ignored"); + + assert_eq!( + fs::read_to_string(repo.path.join(".gitignore")).expect(".gitignore should exist"), + "/cache/\n" + ); + assert_eq!( + git_output_test(&repo.path, ["ls-files"]), + "cache-old/keep.tmp" + ); + assert_eq!( + git_output_test(&repo.path, ["check-ignore", "cache/nested/result.tmp"]), + "cache/nested/result.tmp" + ); + } + + #[test] + fn untrack_paths_keep_worktree_files_and_only_remove_the_selected_scope() { + let repo = init_temp_repo("untrack_paths"); + fs::create_dir_all(repo.path.join("generated/nested")) + .expect("tracked directory should be created"); + fs::write(repo.path.join("generated/nested/output.bin"), "original\n") + .expect("tracked file should be written"); + fs::write(repo.path.join("keep.txt"), "keep\n").expect("kept file should be written"); + run_git_test(&repo.path, ["add", "."]); + run_git_test(&repo.path, ["commit", "-m", "initial"]); + fs::write( + repo.path.join("generated/nested/output.bin"), + "local change\n", + ) + .expect("tracked file should be modified"); + + let targets = vec!["generated".to_string()]; + untrack_paths_for_repo(&repo.path, &targets).expect("folder should be untracked"); + + assert_eq!( + fs::read_to_string(repo.path.join("generated/nested/output.bin")) + .expect("working-tree file should remain"), + "local change\n" + ); + assert_eq!(git_output_test(&repo.path, ["ls-files"]), "keep.txt"); + assert!(repo.path.join("keep.txt").is_file()); + } } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 15daaf3..69d87dd 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -10,8 +10,8 @@ use external_tools::{ detect_external_tools, launch_external_diff, launch_external_merge, launch_external_tool, }; use git::{ - SearchCancellationState, add_remote, add_worktree, amend_commit, apply_file_patch, - cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort, + SearchCancellationState, add_remote, add_to_gitignore, add_worktree, amend_commit, + apply_file_patch, cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort, cherry_pick_commit, cherry_pick_continue, clone_repository, commit, commit_ai_generate, commit_ai_load, commit_ai_local_models, commit_ai_review, commit_ai_split, commit_ai_status, compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, create_tag, @@ -30,7 +30,7 @@ use git::{ revert_commit, run_sequence_editor_if_requested, search_code_introductions, set_branch_upstream, set_commit_note, stage_files, start_interactive_rebase, stash_apply, stash_drop, stash_pop, stash_push, undo_last_commit, unlock_worktree, unstage_files, - update_remote, + untrack_paths, update_remote, }; use std::path::{Path, PathBuf}; use std::sync::Mutex; @@ -254,6 +254,8 @@ async fn main() { cherry_pick_abort, stage_files, unstage_files, + add_to_gitignore, + untrack_paths, stash_push, stash_apply, stash_pop, diff --git a/src/App.svelte b/src/App.svelte index b8640bf..4a4c1f6 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -36,6 +36,7 @@ import UpdateToast from "./lib/components/UpdateToast.svelte"; import { + addToGitignore, amendCommit, addRemote, addWorktree, @@ -137,6 +138,7 @@ undoLastCommit, unlockWorktree, untrackGitLfsPattern, + untrackPaths, unstageFiles, } from "./lib/git"; @@ -161,6 +163,7 @@ GitCommitComparison, GitDiffFile, GitFileStatus, + GitIgnoreKind, GitLfsStatus, GitRepositoryFile, GitRemote, @@ -3947,6 +3950,25 @@ }); } + async function ignoreStatusTarget(target: string, kind: GitIgnoreKind) { + if (!activeRepoPath || !target) return; + const description = kind === "folder" ? "folder" : kind === "extension" ? "file extension" : "file"; + await runOperation(`Ignoring ${description}`, async () => { + applyStatus(await addToGitignore(activeRepoPath, target, kind)); + await refreshExplorerFiles(activeRepoPath); + trackEvent("gitignore_rule_added", { kind }); + }); + } + + async function stopTrackingTarget(target: string, kind: "file" | "folder") { + if (!activeRepoPath || !target) return; + await runOperation(`Stopping tracking for ${kind}`, async () => { + applyStatus(await untrackPaths(activeRepoPath, [target])); + await refreshExplorerFiles(activeRepoPath); + trackEvent("git_paths_untracked", { kind }); + }); + } + function discardFiles(files: GitFileStatus[], staged: boolean) { if (!activeRepoPath || isBusy || files.length === 0) return; pendingDiscard = { kind: "file", files, staged }; @@ -5309,6 +5331,8 @@ onExternalDiff={compareExplorerFileExternally} onFileHistory={openFileHistoryDialog} onBlame={openBlame} + onIgnore={ignoreStatusTarget} + onStopTracking={stopTrackingTarget} collapsed={explorerPanelCollapsed} onToggleCollapsed={toggleExplorerPanelCollapsed} /> @@ -5378,6 +5402,8 @@ onDiscard={discardFiles} onDiscardMany={discardChanges} onStash={stashStatusFiles} + onIgnore={ignoreStatusTarget} + onStopTracking={stopTrackingTarget} onPatch={openPreferredFileDiff} onStageAll={stageAllFiles} onUnstageAll={unstageAllFiles} diff --git a/src/app.css b/src/app.css index 5ca1a6d..4af95bc 100644 --- a/src/app.css +++ b/src/app.css @@ -2350,8 +2350,20 @@ .status-context-menu, .repo-tab-context-menu { position: fixed; } + .explorer-context-menu { + box-sizing: border-box; + width: min(240px, calc(100vw - 16px)); + max-height: calc(100vh - 16px); + overflow-y: auto; + overflow-x: hidden; + } + .status-context-menu { + box-sizing: border-box; width: min(280px, calc(100vw - 16px)); + max-height: calc(100vh - 16px); + overflow-y: auto; + overflow-x: hidden; padding: 6px; border-color: color-mix(in srgb, var(--color-border) 78%, #5a8cf8); background: @@ -2461,11 +2473,26 @@ } .status-context-menu button { + box-sizing: border-box; + max-width: 100%; min-height: 42px; gap: 9px; padding: 6px 7px; + overflow: hidden; } + .explorer-context-menu button { + box-sizing: border-box; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .explorer-context-menu button svg { flex: 0 0 auto; } + .explorer-context-menu button.ignore { color: #91b59a; } + .explorer-context-menu button.untrack { color: #d7ad6d; } + .status-context-action-icon { width: 26px; height: 26px; @@ -2475,17 +2502,26 @@ color: var(--color-ink-dim); } - .status-context-action-copy { gap: 1px; } + .status-context-action-copy { + gap: 1px; + overflow: hidden; + } .status-context-action-copy strong { + overflow: hidden; color: var(--color-ink-muted); font-size: 11.5px; line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; } .status-context-action-copy span { + overflow: hidden; color: var(--color-ink-faint); font-size: 9.5px; font-weight: 500; line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; } .branch-context-menu button:hover:not(:disabled), @@ -2506,8 +2542,18 @@ color: var(--color-ink); } + .status-context-action-icon.ignore { + color: #91b59a; + } + + .status-context-action-icon.untrack { + color: #d7ad6d; + } + .branch-context-menu .menu-separator, .history-context-menu .menu-separator, + .explorer-context-menu .menu-separator, + .status-context-menu .menu-separator, .repo-tab-context-menu .menu-separator { height: 1px; margin: 4px 3px; diff --git a/src/lib/components/ExplorerPanel.svelte b/src/lib/components/ExplorerPanel.svelte index 49b7023..3553748 100644 --- a/src/lib/components/ExplorerPanel.svelte +++ b/src/lib/components/ExplorerPanel.svelte @@ -11,20 +11,24 @@ FileCog, FileImage, FileJson, + FileMinus2, FileSearch, GitCompare, FileSpreadsheet, FileText, FileType, FileVideo, + FileX, Folder, + FolderMinus, FolderOpen, + FolderX, ExternalLink, History, Terminal, } from "@lucide/svelte"; import { languageIconForPath } from "../languageIcons"; - import type { ExplorerNode, ExplorerNodeKind, FileStatusKind, GitRepositoryFile } from "../types"; + import type { ExplorerNode, ExplorerNodeKind, FileStatusKind, GitIgnoreKind, GitRepositoryFile } from "../types"; import LanguageIcon from "./LanguageIcon.svelte"; interface Props { @@ -46,6 +50,8 @@ onExternalDiff: (node: ExplorerNode) => void; onFileHistory: (node: ExplorerNode) => void; onBlame: (node: ExplorerNode) => void; + onIgnore: (target: string, kind: GitIgnoreKind) => void; + onStopTracking: (target: string, kind: "file" | "folder") => void; collapsed?: boolean; onToggleCollapsed?: () => void; } @@ -69,6 +75,8 @@ onExternalDiff = () => {}, onFileHistory = () => {}, onBlame = () => {}, + onIgnore = () => {}, + onStopTracking = () => {}, collapsed = false, onToggleCollapsed = () => {}, }: Props = $props(); @@ -76,6 +84,7 @@ let contextNode = $state(null); let contextMenuX = $state(0); let contextMenuY = $state(0); + let contextMenuElement = $state(null); const isGerman = $derived(language === "de"); function mergeExplorerStatus(current: FileStatusKind | null, next: FileStatusKind | null): FileStatusKind | null { @@ -183,14 +192,19 @@ return "text"; } - function openFileContextMenu(event: MouseEvent, node: ExplorerNode) { - if (node.kind !== "file") return; + function openNodeContextMenu(event: MouseEvent, node: ExplorerNode) { event.preventDefault(); event.stopPropagation(); contextNode = node; - contextMenuX = Math.max(8, Math.min(event.clientX + 2, window.innerWidth - 192)); - contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - 220)); + contextMenuX = Math.max(8, Math.min(event.clientX + 2, window.innerWidth - 248)); + contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - 320)); + requestAnimationFrame(() => { + if (!contextMenuElement) return; + const bounds = contextMenuElement.getBoundingClientRect(); + contextMenuX = Math.max(8, Math.min(event.clientX + 2, window.innerWidth - bounds.width - 8)); + contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - bounds.height - 8)); + }); } function closeFileContextMenu() { @@ -232,6 +246,31 @@ onFileHistory(node); } + function explorerFileNodes(node: ExplorerNode): ExplorerNode[] { + if (node.kind === "file") return [node]; + return node.children.flatMap(explorerFileNodes); + } + + function isIgnoreableExplorerFile(node: ExplorerNode): boolean { + return node.kind === "file" && !node.tracked && node.path.replace(/\\/g, "/").toLowerCase() !== ".gitignore"; + } + + function runContextIgnore(kind: GitIgnoreKind) { + const node = contextNode; + if (!node) return; + if (kind === "folder" && node.kind !== "folder") return; + if ((kind === "file" || kind === "extension") && node.kind !== "file") return; + closeFileContextMenu(); + onIgnore(node.path, kind); + } + + function runContextStopTracking() { + const node = contextNode; + if (!node) return; + closeFileContextMenu(); + onStopTracking(node.path, node.kind); + } + function handleWindowKeydown(event: KeyboardEvent) { if (event.key === "Escape") closeFileContextMenu(); } @@ -239,6 +278,10 @@ let explorerTree = $derived(buildExplorerTree(repoFiles)); let visibleNodes = $derived(flattenExplorerTree(explorerTree, expandedExplorerPaths)); let hasFolders = $derived(explorerTree.some((node) => node.kind === "folder")); + let contextFiles = $derived(contextNode ? explorerFileNodes(contextNode) : []); + let contextCanIgnore = $derived(contextFiles.some(isIgnoreableExplorerFile)); + let contextCanStopTracking = $derived(contextFiles.some((node) => node.tracked)); + let contextIgnoreExtension = $derived(contextNode?.kind === "file" && contextCanIgnore ? extensionFor(contextNode.path) : ""); let selectedFileNode = $derived( selectedExplorerKind === "file" ? visibleNodes.find((node) => node.kind === "file" && node.path === selectedExplorerPath) ?? null @@ -338,7 +381,7 @@ class:folder={node.kind === "folder"} style={`--depth: ${node.depth}`} title={node.path} - oncontextmenu={(event) => openFileContextMenu(event, node)} + oncontextmenu={(event) => openNodeContextMenu(event, node)} > {#if node.kind === "folder"} - - - - + {#if contextNode.kind === "file"} + + + + + + {/if} + {#if contextCanStopTracking || contextCanIgnore} + + {/if} + {#if contextCanStopTracking} + + {/if} + {#if contextCanIgnore && contextNode.kind === "file"} + + {#if contextIgnoreExtension} + + {/if} + {:else if contextCanIgnore && contextNode.kind === "folder"} + + {/if} {/if} diff --git a/src/lib/components/HelpOverlay.svelte b/src/lib/components/HelpOverlay.svelte index a30de90..73dc605 100644 --- a/src/lib/components/HelpOverlay.svelte +++ b/src/lib/components/HelpOverlay.svelte @@ -1530,7 +1530,7 @@ "Nach einem erfolgreichen Pull erkennt Gitty LFS-Repositories automatisch und lädt die benötigten LFS-Objekte mit demselben Remote und denselben Zugangsdaten. Ein zweiter manueller Pull ist nicht erforderlich.", "Unstaged und Staged stehen jetzt gleich breit nebeneinander, scrollen unabhängig voneinander und verwenden eindeutige Pfeile für Stage und Unstage. Bei schmalen Fenstern wechselt die Darstellung automatisch untereinander.", "Der mittig angeordnete List-/Tree-Umschalter zeigt Änderungen entweder als kompakte Liste oder gruppiert sie in beiden Bereichen nach aufklappbaren Ordnern.", - "Über das neu gestaltete Kontextmenü einer Datei oder eines Ordners lassen sich gezielt einzelne Dateien oder alle Änderungen im Ordner stagen, unstagen oder in einem eigenen Stash sichern. Dateiname, übergeordneter Pfad und Anzahl der betroffenen Dateien sind dabei klar voneinander getrennt.", + "Über das neu gestaltete Kontextmenü einer Datei oder eines Ordners lassen sich gezielt einzelne Dateien oder alle Änderungen im Ordner stagen, unstagen oder in einem eigenen Stash sichern. Neue und ungetrackte Inhalte können im Changes-Bereich und im File Explorer als exakte Datei, kompletter Ordner oder Dateiendungs-Muster in die .gitignore übernommen werden; die Ordneroption erscheint nur beim Rechtsklick auf einen Ordner. Bereits getrackte Dateien und Ordner lassen sich mit „Stop tracking“ aus dem Git-Index entfernen, bleiben aber auf der Festplatte erhalten. Dateiname, übergeordneter Pfad und Anzahl der betroffenen Dateien sind dabei klar voneinander getrennt.", "Repositories können beim Start über --repo PATH oder --repo=PATH direkt geöffnet werden. Relative Pfade werden dabei aufgelöst.", "Quadratische Bedienelemente und Flächen vereinheitlichen das Erscheinungsbild; runde Statuspunkte, Avatare und charakteristische Branch-Markierungen bleiben erhalten.", ], @@ -1646,7 +1646,7 @@ "After a successful pull, Gitty automatically detects LFS repositories and downloads the required LFS objects with the same remote and credentials. A second manual pull is no longer required.", "Unstaged and Staged now sit side by side at equal width, scroll independently, and use clear arrows for Stage and Unstage. Narrow windows automatically fall back to a vertical layout.", "The centered List/Tree switch presents changes either as a compact list or groups them into collapsible folders in both areas.", - "The redesigned file and folder context menu can stage, unstage, or save only that file or the folder's complete set of changes in a dedicated stash. The selected name, parent path, and affected file count are now clearly separated.", + "The redesigned file and folder context menu can stage, unstage, or save only that file or the folder's complete set of changes in a dedicated stash. In Changes and the File Explorer, new and untracked items can be added to .gitignore as an exact file, a complete folder, or an extension-wide pattern; the folder option only appears for folder selections. Tracked files and folders can be removed from the Git index with Stop tracking while remaining on disk. The selected name, parent path, and affected file count are now clearly separated.", "Repositories can be opened directly at startup with --repo PATH or --repo=PATH. Relative paths are resolved automatically.", "Square controls and surfaces make the interface more consistent while circular status markers, avatars, and characteristic branch shapes remain intact.", ], diff --git a/src/lib/components/StatusPanel.svelte b/src/lib/components/StatusPanel.svelte index e40abc9..7bea36c 100644 --- a/src/lib/components/StatusPanel.svelte +++ b/src/lib/components/StatusPanel.svelte @@ -4,13 +4,18 @@ ArrowLeft, ArrowRight, FileDiff, + FileMinus2, + FileType, + FileX, Folder, FolderOpen, + FolderMinus, FolderTree, + FolderX, RotateCcw, } from "@lucide/svelte"; import iconUrl from "../../../src-tauri/icons/icon.png"; - import type { FileStatusKind, GitFileStatus, GitStatus } from "../types"; + import type { FileStatusKind, GitFileStatus, GitIgnoreKind, GitStatus } from "../types"; interface Props { changedFiles: GitFileStatus[]; @@ -27,6 +32,8 @@ onDiscard: (files: GitFileStatus[], staged: boolean) => void; onDiscardMany: (files: GitFileStatus[]) => void; onStash: (files: GitFileStatus[], label: string) => void; + onIgnore: (target: string, kind: GitIgnoreKind) => void; + onStopTracking: (target: string, kind: "file" | "folder") => void; onPatch: (file: GitFileStatus, staged: boolean) => void; onStageAll: () => void; onUnstageAll: () => void; @@ -76,6 +83,8 @@ onDiscard = () => {}, onDiscardMany = () => {}, onStash = () => {}, + onIgnore = () => {}, + onStopTracking = () => {}, onPatch = () => {}, onStageAll = () => {}, onUnstageAll = () => {}, @@ -188,6 +197,7 @@ let statusContextTarget = $state(null); let statusContextMenuX = $state(0); let statusContextMenuY = $state(0); + let statusContextMenuElement = $state(null); function toggleStatusFolder(lane: StatusLaneKind, path: string) { const next = new Set(collapsedStatusFolders); @@ -220,6 +230,12 @@ statusContextMenuX = Math.max(8, Math.min(event.clientX, window.innerWidth - 288)); statusContextMenuY = Math.max(8, Math.min(event.clientY, window.innerHeight - 174)); statusContextTarget = { lane, kind, label, files }; + requestAnimationFrame(() => { + if (!statusContextMenuElement) return; + const bounds = statusContextMenuElement.getBoundingClientRect(); + statusContextMenuX = Math.max(8, Math.min(event.clientX, window.innerWidth - bounds.width - 8)); + statusContextMenuY = Math.max(8, Math.min(event.clientY, window.innerHeight - bounds.height - 8)); + }); } function statusContextName(label: string): string { @@ -252,6 +268,45 @@ onStash(target.files, target.label); } + function isIgnoreableNewFile(file: GitFileStatus): boolean { + const path = file.path.replace(/\\/g, "/").toLowerCase(); + if (path === ".gitignore") return false; + return file.unstaged === "untracked" || (file.staged === "added" && file.old_path === null); + } + + function isTrackedStatusFile(file: GitFileStatus): boolean { + return file.unstaged !== "untracked" && file.staged !== "deleted"; + } + + function statusContextExtension(label: string): string { + const name = statusContextName(label); + const separator = name.lastIndexOf("."); + return separator > 0 && separator < name.length - 1 ? name.slice(separator + 1) : ""; + } + + function statusContextFolder(target: StatusContextTarget): string { + if (target.kind === "folder") return target.label.replace(/\\/g, "/").replace(/\/+$/, ""); + const normalized = target.label.replace(/\\/g, "/"); + const separator = normalized.lastIndexOf("/"); + return separator > 0 ? normalized.slice(0, separator) : ""; + } + + function runStatusContextIgnoreAction(kind: GitIgnoreKind) { + const target = statusContextTarget; + if (!target) return; + const ignoreTarget = kind === "folder" ? statusContextFolder(target) : target.label; + if (!ignoreTarget) return; + closeStatusContextMenu(); + onIgnore(ignoreTarget, kind); + } + + function runStatusContextStopTracking() { + const target = statusContextTarget; + if (!target) return; + closeStatusContextMenu(); + onStopTracking(target.label, target.kind); + } + function handleStatusWindowKeydown(event: KeyboardEvent) { if (event.key === "Escape") closeStatusContextMenu(); } @@ -340,6 +395,10 @@ let visibleStagedRows = $derived(statusView === "tree" ? flattenStatusTree(stagedTree, "staged") : listStatusRows(stagedFiles)); let selectedUnstagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.unstaged !== null).length); let selectedStagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.staged !== null).length); + let statusContextCanIgnore = $derived(statusContextTarget?.files.some(isIgnoreableNewFile) ?? false); + let statusContextCanStopTracking = $derived(statusContextTarget?.files.some(isTrackedStatusFile) ?? false); + let statusContextIgnoreExtension = $derived(statusContextTarget?.kind === "file" ? statusContextExtension(statusContextTarget.label) : ""); + let statusContextIgnoreFolder = $derived(statusContextTarget ? statusContextFolder(statusContextTarget) : ""); $effect(() => { const validKeys = new Set(changedFiles.map(fileKey)); @@ -517,7 +576,7 @@ {#if statusContextTarget} -