feat(git): add ignore and untrack paths commands
The changes introduce server-side commands to manage gitignore rules and to untrack paths without deleting local files. A new GitIgnoreKind enum and helper functions normalize targets and build proper ignore patterns, and UI code was wired to use these commands. - add_to_gitignore command and related helpers - untrack_paths command to remove paths from the index - UI wiring to expose ignore and untrack actions in explorer
This commit is contained in:
+5
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -137,6 +137,8 @@ The command list below includes the repository-management and synchronization AP
|
||||
- `repair_worktree(path: string, worktreePath: string): Promise<GitWorktree[]>`
|
||||
- `stage_files(path: string, files: string[]): Promise<GitStatus>`
|
||||
- `unstage_files(path: string, files: string[]): Promise<GitStatus>`
|
||||
- `add_to_gitignore(path: string, target: string, kind: "file" | "extension" | "folder"): Promise<GitStatus>`; appends a repository-root `.gitignore` rule and unstages newly-added matching files.
|
||||
- `untrack_paths(path: string, targets: string[]): Promise<GitStatus>`; removes files or folders from the Git index while preserving their working-tree contents.
|
||||
- `restore_files(path: string, files: string[], staged: boolean): Promise<GitStatus>`
|
||||
- `stash_push(path: string, message?: string, includeUntracked?: boolean, paths?: string[]): Promise<GitStatus>`; when `paths` is provided, only matching files are stashed.
|
||||
- `commit(path: string, message: string): Promise<GitStatus>`
|
||||
|
||||
@@ -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<String>) -> Result<GitStatus
|
||||
.map_err(|err| format!("Could not unstage files: {err}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_to_gitignore(
|
||||
path: String,
|
||||
target: String,
|
||||
kind: GitIgnoreKind,
|
||||
) -> Result<GitStatus, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
||||
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<String>) -> Result<GitStatus, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
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<bool, String> {
|
||||
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<GitStatus, String> {
|
||||
let target = normalize_gitignore_target(target)?;
|
||||
let pattern = gitignore_pattern(&target, kind)?;
|
||||
let status = status_for_repo(repo)?;
|
||||
let staged_additions: Vec<String> = 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<GitStatus, String> {
|
||||
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<PathBuf, String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
+47
-1
@@ -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;
|
||||
|
||||
@@ -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<ExplorerNode | null>(null);
|
||||
let contextMenuX = $state(0);
|
||||
let contextMenuY = $state(0);
|
||||
let contextMenuElement = $state<HTMLDivElement | null>(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"}
|
||||
<button
|
||||
@@ -420,49 +463,60 @@
|
||||
|
||||
{#if contextNode}
|
||||
<div
|
||||
bind:this={contextMenuElement}
|
||||
class="explorer-context-menu"
|
||||
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
aria-label={`Actions for ${contextNode.path}`}
|
||||
>
|
||||
<button type="button" role="menuitem" onclick={openContextFileInEditor} disabled={contextNode.status === "deleted"}>
|
||||
<FileCode size={14} aria-hidden="true" />
|
||||
{isGerman ? `In ${editorName} öffnen` : `Open in ${editorName}`}
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={openContextExternalDiff} disabled={!contextNode.tracked || contextNode.status === "deleted"}>
|
||||
<GitCompare size={14} aria-hidden="true" />
|
||||
{isGerman ? `Mit HEAD in ${diffName} vergleichen` : `Compare with HEAD in ${diffName}`}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onclick={openContextFileHistory}
|
||||
disabled={!contextNode.tracked}
|
||||
title={contextNode.tracked ? "Show the commit history for this file" : "File history is only available for tracked files"}
|
||||
>
|
||||
<History size={14} aria-hidden="true" />
|
||||
File history
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onclick={openContextFile}
|
||||
disabled={contextNode.status === "deleted"}
|
||||
title={contextNode.status === "deleted" ? "Deleted files cannot be revealed in Explorer" : "Reveal this file in Explorer"}
|
||||
>
|
||||
<ExternalLink size={14} aria-hidden="true" />
|
||||
Open in Explorer
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onclick={openContextBlame}
|
||||
disabled={!contextNode.tracked || contextNode.status === "deleted"}
|
||||
title={!contextNode.tracked || contextNode.status === "deleted" ? "Blame is only available for tracked files" : "Show blame for this file"}
|
||||
>
|
||||
<FileSearch size={14} aria-hidden="true" />
|
||||
Blame
|
||||
</button>
|
||||
{#if contextNode.kind === "file"}
|
||||
<button type="button" role="menuitem" onclick={openContextFileInEditor} disabled={contextNode.status === "deleted"}>
|
||||
<FileCode size={14} aria-hidden="true" />
|
||||
{isGerman ? `In ${editorName} öffnen` : `Open in ${editorName}`}
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={openContextExternalDiff} disabled={!contextNode.tracked || contextNode.status === "deleted"}>
|
||||
<GitCompare size={14} aria-hidden="true" />
|
||||
{isGerman ? `Mit HEAD in ${diffName} vergleichen` : `Compare with HEAD in ${diffName}`}
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={openContextFileHistory} disabled={!contextNode.tracked} title={contextNode.tracked ? "Show the commit history for this file" : "File history is only available for tracked files"}>
|
||||
<History size={14} aria-hidden="true" />
|
||||
{isGerman ? "Dateiverlauf" : "File history"}
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={openContextFile} disabled={contextNode.status === "deleted"} title={contextNode.status === "deleted" ? "Deleted files cannot be revealed in Explorer" : "Reveal this file in Explorer"}>
|
||||
<ExternalLink size={14} aria-hidden="true" />
|
||||
{isGerman ? "Im Explorer öffnen" : "Open in Explorer"}
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={openContextBlame} disabled={!contextNode.tracked || contextNode.status === "deleted"} title={!contextNode.tracked || contextNode.status === "deleted" ? "Blame is only available for tracked files" : "Show blame for this file"}>
|
||||
<FileSearch size={14} aria-hidden="true" />
|
||||
Blame
|
||||
</button>
|
||||
{/if}
|
||||
{#if contextCanStopTracking || contextCanIgnore}
|
||||
<div class="menu-separator" role="separator"></div>
|
||||
{/if}
|
||||
{#if contextCanStopTracking}
|
||||
<button class="untrack" type="button" role="menuitem" onclick={runContextStopTracking} disabled={isBusy} title="Keep the working-tree content and remove it from the Git index">
|
||||
{#if contextNode.kind === "folder"}<FolderMinus size={14} aria-hidden="true" />{:else}<FileMinus2 size={14} aria-hidden="true" />{/if}
|
||||
{isGerman ? `${contextNode.kind === "folder" ? "Ordner" : "Datei"} nicht mehr tracken` : `Stop tracking ${contextNode.kind}`}
|
||||
</button>
|
||||
{/if}
|
||||
{#if contextCanIgnore && contextNode.kind === "file"}
|
||||
<button class="ignore" type="button" role="menuitem" onclick={() => runContextIgnore("file")} disabled={isBusy} title={`Add /${contextNode.path.replace(/\\/g, "/")} to .gitignore`}>
|
||||
<FileX size={14} aria-hidden="true" />
|
||||
{isGerman ? "Datei ignorieren" : "Ignore file"}
|
||||
</button>
|
||||
{#if contextIgnoreExtension}
|
||||
<button class="ignore" type="button" role="menuitem" onclick={() => runContextIgnore("extension")} disabled={isBusy} title={`Add *.${contextIgnoreExtension} to .gitignore`}>
|
||||
<FileType size={14} aria-hidden="true" />
|
||||
{isGerman ? `Alle *.${contextIgnoreExtension}-Dateien ignorieren` : `Ignore all *.${contextIgnoreExtension} files`}
|
||||
</button>
|
||||
{/if}
|
||||
{:else if contextCanIgnore && contextNode.kind === "folder"}
|
||||
<button class="ignore" type="button" role="menuitem" onclick={() => runContextIgnore("folder")} disabled={isBusy} title={`Add /${contextNode.path.replace(/\\/g, "/").replace(/\/+$/, "")}/ to .gitignore`}>
|
||||
<FolderX size={14} aria-hidden="true" />
|
||||
{isGerman ? "Ordner ignorieren" : "Ignore folder"}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -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.",
|
||||
],
|
||||
|
||||
@@ -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<StatusContextTarget | null>(null);
|
||||
let statusContextMenuX = $state(0);
|
||||
let statusContextMenuY = $state(0);
|
||||
let statusContextMenuElement = $state<HTMLDivElement | null>(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 @@
|
||||
</section>
|
||||
|
||||
{#if statusContextTarget}
|
||||
<div class="status-context-menu" style={`left: ${statusContextMenuX}px; top: ${statusContextMenuY}px;`} role="menu" tabindex="-1" aria-label={`Actions for ${statusContextTarget.label}`} onclick={(event) => event.stopPropagation()} onkeydown={(event) => { if (event.key === "Escape") closeStatusContextMenu(); event.stopPropagation(); }}>
|
||||
<div bind:this={statusContextMenuElement} class="status-context-menu" style={`left: ${statusContextMenuX}px; top: ${statusContextMenuY}px;`} role="menu" tabindex="-1" aria-label={`Actions for ${statusContextTarget.label}`} onclick={(event) => event.stopPropagation()} onkeydown={(event) => { if (event.key === "Escape") closeStatusContextMenu(); event.stopPropagation(); }}>
|
||||
<div class="status-context-label">
|
||||
<span class="status-context-object-icon" aria-hidden="true">
|
||||
{#if statusContextTarget.kind === "folder"}<FolderOpen size={16} />{:else}<FileDiff size={16} />{/if}
|
||||
@@ -547,6 +606,49 @@
|
||||
<span>Save {statusContextTarget.files.length === 1 ? "this file" : `${statusContextTarget.files.length} files`} for later</span>
|
||||
</span>
|
||||
</button>
|
||||
{#if statusContextCanIgnore || statusContextCanStopTracking}
|
||||
<div class="menu-separator" role="separator"></div>
|
||||
{/if}
|
||||
{#if statusContextCanStopTracking}
|
||||
<button type="button" role="menuitem" onclick={runStatusContextStopTracking} disabled={isBusy} title="Keep the working-tree content and remove it from the Git index">
|
||||
<span class="status-context-action-icon untrack" aria-hidden="true">
|
||||
{#if statusContextTarget.kind === "folder"}<FolderMinus size={15} />{:else}<FileMinus2 size={15} />{/if}
|
||||
</span>
|
||||
<span class="status-context-action-copy">
|
||||
<strong>Stop tracking {statusContextTarget.kind}</strong>
|
||||
<span>Keep it on disk and remove it from Git</span>
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if statusContextCanIgnore}
|
||||
{#if statusContextTarget.kind === "file"}
|
||||
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("file")} disabled={isBusy} title={`Add /${statusContextTarget.label.replace(/\\/g, "/")} to .gitignore`}>
|
||||
<span class="status-context-action-icon ignore" aria-hidden="true"><FileX size={15} /></span>
|
||||
<span class="status-context-action-copy">
|
||||
<strong>Ignore file</strong>
|
||||
<span>Add only this file to .gitignore</span>
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if statusContextIgnoreExtension}
|
||||
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("extension")} disabled={isBusy} title={`Add *.${statusContextIgnoreExtension} to .gitignore`}>
|
||||
<span class="status-context-action-icon ignore" aria-hidden="true"><FileType size={15} /></span>
|
||||
<span class="status-context-action-copy">
|
||||
<strong>Ignore all *.{statusContextIgnoreExtension} files</strong>
|
||||
<span>Match this file type repository-wide</span>
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if statusContextTarget.kind === "folder" && statusContextIgnoreFolder}
|
||||
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("folder")} disabled={isBusy} title={`Add /${statusContextIgnoreFolder}/ to .gitignore`}>
|
||||
<span class="status-context-action-icon ignore" aria-hidden="true"><FolderX size={15} /></span>
|
||||
<span class="status-context-action-copy">
|
||||
<strong>Ignore folder</strong>
|
||||
<span>Add this folder and its contents to .gitignore</span>
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
GitBranch,
|
||||
GitCommit,
|
||||
GitCommitComparison,
|
||||
GitIgnoreKind,
|
||||
GitLfsStatus,
|
||||
GitRepositoryFile,
|
||||
GitRemote,
|
||||
@@ -291,6 +292,14 @@ export function unstageFiles(path: string, files: string[]): Promise<GitStatus>
|
||||
return invoke<GitStatus>("unstage_files", { path, files });
|
||||
}
|
||||
|
||||
export function addToGitignore(path: string, target: string, kind: GitIgnoreKind): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("add_to_gitignore", { path, target, kind });
|
||||
}
|
||||
|
||||
export function untrackPaths(path: string, targets: string[]): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("untrack_paths", { path, targets });
|
||||
}
|
||||
|
||||
export function restoreFiles(
|
||||
path: string,
|
||||
files: string[],
|
||||
|
||||
@@ -7,6 +7,8 @@ export type FileStatusKind =
|
||||
| "conflicted"
|
||||
| "unknown";
|
||||
|
||||
export type GitIgnoreKind = "file" | "extension" | "folder";
|
||||
|
||||
export type CommitAiPhase = "idle" | "loading" | "ready" | "error";
|
||||
export type CommitAiProvider = "local" | "openai" | "anthropic" | "custom";
|
||||
export type CommitAiLocalProfile = "fast" | "balanced" | "detailed";
|
||||
|
||||
Reference in New Issue
Block a user