feat: middle-click close tab, git status colors & diff preview#117
feat: middle-click close tab, git status colors & diff preview#117ajianaz wants to merge 5 commits into
Conversation
Add onAuxClick handler (button === 1) to all three tab types: - TerminalTabInline: with !isClosing guard - EditorTabWrapper: closes editor tab - BrowserTabInline: closes browser tab Standard browser middle-click behavior — no need to find the X button.
- Rust: new command project_git_file_statuses parses git status --porcelain into per-file entries with status (modified/added/deleted/untracked/renamed/conflicted) and staged flag - Frontend: git-file-status-store polls every 5s, maps absolute paths to status - FileTreeNode: colored status indicator (M/A/D/U/R/C) appears on changed files - FileExplorer: starts/stops polling based on active project rootPath Colors: Modified=amber, Added=emerald, Deleted=red, Untracked=emerald-400, Renamed=blue, Conflicted=orange
Icon and filename now change color based on git status (like VS Code), not just the small indicator letter. The indicator letter is now slightly transparent to not compete with the colored name.
- Rust: project_git_diff_file command (unstaged then staged fallback) - DiffViewer: unified diff renderer with color-coded lines - EditorToolbar: added Diff button next to TOC/Source/Preview - Shows for ALL files with git changes (not just markdown) - Source/Preview disabled in diff mode - TOC still accessible in diff mode - EditorPanel: manages diff state, toolbar visibility, and content switching
Folders now show git status color when they contain changed files, so deeply nested changes are visible from the root of the explorer. - git-file-status-store: propagate file status up to parent directories with priority ordering (conflicted > modified > added > untracked) - FileTreeNode: git status color takes precedence over default folder blue color
📝 WalkthroughWalkthroughThis PR introduces Git file status tracking and unified diff viewing across the application. Changes span from Rust backend methods ( ChangesGit File Status and Diff Viewing Feature
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 7
🧹 Nitpick comments (2)
src/renderer/lib/tauri-git-api.ts (1)
43-46: 💤 Low value
projectGitDiffFileis declared after thegitApiobject that references it.Function declarations are hoisted so this is not a runtime bug, but it is surprising — a reader scanning top-to-bottom will see
projectGitDiffFileused in the exported object before its implementation. Moving the declaration abovegitApi(matchingprojectGitFileStatuses) removes the ambiguity.♻️ Suggested reorder
+async function projectGitDiffFile( + projectPath: string, + filePath: string, +): Promise<string> { + // ... implementation ... +} + export const gitApi = { projectGitFileStatuses, projectGitDiffFile, } - -async function projectGitDiffFile( - projectPath: string, - filePath: string, -): Promise<string> { - // ... implementation ... -}Also applies to: 48-65
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/renderer/lib/tauri-git-api.ts` around lines 43 - 46, The exported object gitApi references projectGitDiffFile before its implementation, which is confusing; reorder the file so projectGitDiffFile is declared above the gitApi export (matching how projectGitFileStatuses is ordered) and ensure any related helper functions used by projectGitDiffFile are also declared before gitApi to keep top-to-bottom readability consistent.src-tauri/src/commands.rs (1)
172-182: ⚡ Quick winConsider making
project_git_file_statusesandproject_git_diff_fileacceptState<'_, Arc<GitTracker>>and call instance methods instead of static methods.
terminal_get_git_branchandterminal_get_git_statusboth useState<'_, Arc<GitTracker>>and call instance methods (get_branch,get_status). However,project_git_file_statusesandproject_git_diff_filecallGitTracker::get_file_statusesandget_file_diffas static methods without accessing the singleton. This creates an inconsistent pattern: ifGitTrackergains internal caching or state management in the future, these commands would bypass it. Since only one call site exists for each method, converting them to instance methods is low-effort and would align with the existing pattern.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src-tauri/src/commands.rs` around lines 172 - 182, project_git_file_statuses (and similarly project_git_diff_file) currently call GitTracker::get_file_statuses/get_file_diff as static functions; change their signatures to accept State<'_, Arc<GitTracker>> and call the instance methods (e.g. git_tracker.get_file_statuses(...) / git_tracker.get_file_diff(...)) instead, mirroring terminal_get_git_branch and terminal_get_git_status; update any tokio::task::spawn_blocking closures to capture the Arc<GitTracker> clone and invoke the instance method so the singleton's internal state/caching is used consistently.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src-tauri/src/commands.rs`:
- Around line 172-197: The commands project_git_file_statuses and
project_git_diff_file currently wrap whatever GitTracker::get_file_statuses /
GitTracker::get_file_diff return as IpcResult::success even when those tracker
helpers swallow errors and return empty values; change the handlers to detect
failures and return IpcResult::error instead of always success: either
(preferred) update GitTracker::get_file_statuses and get_file_diff to return
Result<T, String> (propagate the actual git error) and then in
project_git_file_statuses / project_git_diff_file map Err to IpcResult::error
with that message, or (if changing trackers is infeasible) have the command
functions inspect the returned value (empty Vec or empty String) and treat that
as an error case by returning IpcResult::error("git unavailable or not a repo")
so the UI can distinguish real empty results from git failures.
In `@src-tauri/src/trackers/git_tracker.rs`:
- Around line 982-984: get_file_diff currently uses string strip_prefix and
trim_start_matches which breaks when cwd has a trailing slash; change
get_file_diff to work with std::path::Path (use Path::new for cwd and
file_path), normalize both paths (trim trailing separators or
canonicalize/clean) and compute the relative path with Path::strip_prefix (or
attempt strip_prefix both with and without a trailing separator) so the
resulting relative path is correct before passing to git; update the code paths
that reference cwd and file_path to use the Path-based relative result and
convert it to a String (e.g., via to_string_lossy) for the git diff call.
- Around line 939-966: The match over (index_status, work_tree_status) in the
git status parsing currently falls through for common combos like ('A','M'),
('M','D') and ('A','D'); add explicit arms in that match (referencing
index_status, work_tree_status and the GitFileStatus enum) so those states are
not skipped: map ('A','M') -> (GitFileStatus::Modified, true) (file staged then
changed in worktree), map ('M','D') -> (GitFileStatus::Deleted, true) (staged
change with worktree deletion), and map ('A','D') -> (GitFileStatus::Deleted,
true) (staged add then removed from disk); insert these arms alongside the other
patterns in the existing match block to ensure these common states are handled.
- Around line 932-936: The constructed absolute path in git_tracker.rs (variable
abs_path built from cwd and file_path) can mix backslashes from cwd with forward
slashes from git output; after joining (in the block that sets abs_path)
normalize the path separators to forward slashes (or use Path/PathBuf
normalization) so the resulting string uses '/' consistently; apply the same
normalization where cwd is used in get_file_diff() (the cwd-based path strip at
lines around get_file_diff) to ensure downstream path matching works on Windows.
In `@src/renderer/components/editor/EditorPanel.tsx`:
- Around line 108-124: The toolbar hiding logic can remove the Diff toggle while
in diff mode; update the showToolbar condition to keep the toolbar visible when
showDiff is true. Specifically, change the computed showToolbar (currently using
isMarkdownFile || hasGitChanges) to include showDiff so that the EditorToolbar
(props: viewMode via fileState.viewMode, onToggleViewMode handleToggleViewMode,
filePath, hasGitChanges, showDiff, onToggleDiff handleToggleDiff) remains
rendered whenever showDiff is active, ensuring users can exit diff mode even if
gitStatus/hasGitChanges becomes false.
In `@src/renderer/lib/tauri-git-api.ts`:
- Around line 30-41: Wrap each call to invoke in project_git_file_statuses (and
the other invoke at lines 54-64) inside a try/catch so IPC-level exceptions
don't propagate during polling: catch any thrown error from
invoke('project_git_file_statuses', { projectPath }) (and the other invoke
call), log or swallow the error as appropriate, and return an empty array or
fallback value when an exception occurs; update the functions in
src/renderer/lib/tauri-git-api.ts that call invoke to use try/catch around the
invoke(...) calls (refer to the invoke call site and the function that returns
result.data) so unhandled rejections are prevented.
In `@src/renderer/stores/git-file-status-store.ts`:
- Around line 46-58: The code uses entry.path.split('/') when walking parent
directories which fails for Windows paths with backslashes; normalize entry.path
to use forward slashes (or split on both separators) before computing parts so
folder statuses propagate correctly (update the place that constructs parts from
entry.path used in the parent-directory walk, which currently calls
entry.path.split('/'), and ensure the rest of the logic that updates map entries
and compares against priority and isStaged remains unchanged).
---
Nitpick comments:
In `@src-tauri/src/commands.rs`:
- Around line 172-182: project_git_file_statuses (and similarly
project_git_diff_file) currently call
GitTracker::get_file_statuses/get_file_diff as static functions; change their
signatures to accept State<'_, Arc<GitTracker>> and call the instance methods
(e.g. git_tracker.get_file_statuses(...) / git_tracker.get_file_diff(...))
instead, mirroring terminal_get_git_branch and terminal_get_git_status; update
any tokio::task::spawn_blocking closures to capture the Arc<GitTracker> clone
and invoke the instance method so the singleton's internal state/caching is used
consistently.
In `@src/renderer/lib/tauri-git-api.ts`:
- Around line 43-46: The exported object gitApi references projectGitDiffFile
before its implementation, which is confusing; reorder the file so
projectGitDiffFile is declared above the gitApi export (matching how
projectGitFileStatuses is ordered) and ensure any related helper functions used
by projectGitDiffFile are also declared before gitApi to keep top-to-bottom
readability consistent.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: be1190ff-12af-4c90-b8da-1f06cea50702
📒 Files selected for processing (11)
src-tauri/src/commands.rssrc-tauri/src/lib.rssrc-tauri/src/trackers/git_tracker.rssrc/renderer/components/editor/DiffViewer.tsxsrc/renderer/components/editor/EditorPanel.tsxsrc/renderer/components/editor/EditorToolbar.tsxsrc/renderer/components/file-explorer/FileExplorer.tsxsrc/renderer/components/file-explorer/FileTreeNode.tsxsrc/renderer/components/workspace/WorkspaceTabBar.tsxsrc/renderer/lib/tauri-git-api.tssrc/renderer/stores/git-file-status-store.ts
| pub async fn project_git_file_statuses( | ||
| project_path: String, | ||
| ) -> Result<IpcResult<Vec<GitFileStatusEntry>>, String> { | ||
| let entries = tokio::task::spawn_blocking(move || { | ||
| crate::trackers::git_tracker::GitTracker::get_file_statuses(&project_path) | ||
| }) | ||
| .await | ||
| .map_err(|e| format!("Task join error: {}", e))?; | ||
|
|
||
| Ok(IpcResult::success(entries)) | ||
| } | ||
|
|
||
| /// Get unified diff for a single file | ||
| #[tauri::command] | ||
| pub async fn project_git_diff_file( | ||
| project_path: String, | ||
| file_path: String, | ||
| ) -> Result<IpcResult<String>, String> { | ||
| let diff = tokio::task::spawn_blocking(move || { | ||
| crate::trackers::git_tracker::GitTracker::get_file_diff(&project_path, &file_path) | ||
| }) | ||
| .await | ||
| .map_err(|e| format!("Task join error: {}", e))?; | ||
|
|
||
| Ok(IpcResult::success(diff)) | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check signatures of get_file_statuses and get_file_diff in git_tracker.rs
rg -n "fn get_file_statuses\|fn get_file_diff" --type rust -A 5Repository: gnoviawan/termul
Length of output: 42
🏁 Script executed:
# Find git_tracker.rs file
fd -type f "git_tracker" --extension rsRepository: gnoviawan/termul
Length of output: 231
🏁 Script executed:
# Also search for the file in the src-tauri directory
find . -path "*src-tauri*" -name "*git*" -type fRepository: gnoviawan/termul
Length of output: 100
🏁 Script executed:
# List the structure of src-tauri/src to understand the layout
ls -la src-tauri/src/ 2>/dev/null || echo "Directory not found, searching for src-tauri"
fd -type d "src-tauri" | head -5Repository: gnoviawan/termul
Length of output: 855
🏁 Script executed:
cat -n ./src-tauri/src/trackers/git_tracker.rsRepository: gnoviawan/termul
Length of output: 50372
🏁 Script executed:
sed -n '172,197p' ./src-tauri/src/commands.rsRepository: gnoviawan/termul
Length of output: 870
Both commands unconditionally return IpcResult::success, making it impossible for callers to distinguish git unavailability from an empty result.
When GitTracker::get_file_statuses (line 907: _ => return Vec::new()) or get_file_diff (line 1016: output.unwrap_or_default()) fail—whether because git is not installed, the path is not a repository, or permissions are denied—they return empty results without any error signal. The commands then wrap these empty results as success: true unconditionally, so the frontend incorrectly treats unavailable git as "no changes."
Return IpcResult::error when the underlying git calls fail, so the UI can handle missing or invalid repositories appropriately.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src-tauri/src/commands.rs` around lines 172 - 197, The commands
project_git_file_statuses and project_git_diff_file currently wrap whatever
GitTracker::get_file_statuses / GitTracker::get_file_diff return as
IpcResult::success even when those tracker helpers swallow errors and return
empty values; change the handlers to detect failures and return IpcResult::error
instead of always success: either (preferred) update
GitTracker::get_file_statuses and get_file_diff to return Result<T, String>
(propagate the actual git error) and then in project_git_file_statuses /
project_git_diff_file map Err to IpcResult::error with that message, or (if
changing trackers is infeasible) have the command functions inspect the returned
value (empty Vec or empty String) and treat that as an error case by returning
IpcResult::error("git unavailable or not a repo") so the UI can distinguish real
empty results from git failures.
| let abs_path = if file_path.starts_with('/') { | ||
| file_path.to_string() | ||
| } else { | ||
| format!("{}/{}", cwd.trim_end_matches('/'), file_path) | ||
| }; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's examine the git_tracker.rs file around the mentioned lines
cd src-tauri && wc -l src/trackers/git_tracker.rsRepository: gnoviawan/termul
Length of output: 93
🏁 Script executed:
# Read the relevant section (lines 920-950 to get full context)
sed -n '920,950p' src/trackers/git_tracker.rsRepository: gnoviawan/termul
Length of output: 131
🏁 Script executed:
# Look at get_file_diff function around line 983-984
sed -n '980,1020p' src/trackers/git_tracker.rsRepository: gnoviawan/termul
Length of output: 131
🏁 Script executed:
# Find where cwd is obtained and how it's constructed
rg 'let cwd' src/trackers/git_tracker.rs -A 5 -B 2Repository: gnoviawan/termul
Length of output: 128
🏁 Script executed:
# Search for how get_file_statuses is called and what cwd value is passed
rg 'get_file_statuses' src/trackers/git_tracker.rs -B 5 -A 2Repository: gnoviawan/termul
Length of output: 128
🏁 Script executed:
# Look for any path normalization or separator handling
rg -i 'replace.*\\|normalize|separator|windows' src/trackers/git_tracker.rsRepository: gnoviawan/termul
Length of output: 128
🏁 Script executed:
# Check Tauri commands that might receive these paths
fd -e rs -e ts -e tsx | xargs grep -l 'entry\.path' | head -10Repository: gnoviawan/termul
Length of output: 320
🏁 Script executed:
# Find git_tracker.rs
find . -name "git_tracker.rs" -type fRepository: gnoviawan/termul
Length of output: 100
🏁 Script executed:
# Also check the directory structure
ls -la src-tauri/src/trackers/Repository: gnoviawan/termul
Length of output: 432
🏁 Script executed:
# Read the relevant section around lines 932-936
sed -n '920,950p' ./src-tauri/src/trackers/git_tracker.rsRepository: gnoviawan/termul
Length of output: 1326
🏁 Script executed:
# Look at the full get_file_statuses function to understand cwd source
sed -n '898,945p' ./src-tauri/src/trackers/git_tracker.rsRepository: gnoviawan/termul
Length of output: 1948
🏁 Script executed:
# Look at get_file_diff function around line 983-984
sed -n '980,1020p' ./src-tauri/src/trackers/git_tracker.rsRepository: gnoviawan/termul
Length of output: 1680
🏁 Script executed:
# Find where cwd is obtained - check the function signature and initial part
sed -n '898,920p' ./src-tauri/src/trackers/git_tracker.rsRepository: gnoviawan/termul
Length of output: 949
🏁 Script executed:
# Search for how this function is called from elsewhere
rg 'get_file_statuses' ./src-tauri/src/trackers/git_tracker.rs -B 3 -A 3Repository: gnoviawan/termul
Length of output: 448
🏁 Script executed:
# Check how git commands are run
sed -n '1,50p' ./src-tauri/src/trackers/git_tracker.rsRepository: gnoviawan/termul
Length of output: 1488
🏁 Script executed:
# Look for run_git_command implementation
rg 'fn run_git_command' ./src-tauri/src/trackers/git_tracker.rs -A 20Repository: gnoviawan/termul
Length of output: 911
🏁 Script executed:
# Find where get_file_statuses is called from
rg 'get_file_statuses' ./src-tauri/src/ -B 2 -A 2Repository: gnoviawan/termul
Length of output: 914
🏁 Script executed:
# Check how cwd is obtained in the commands module
rg -i 'cwd' ./src-tauri/src/commands.rs | head -20Repository: gnoviawan/termul
Length of output: 389
🏁 Script executed:
# Look at commands.rs to see if there's path normalization
head -100 ./src-tauri/src/commands.rsRepository: gnoviawan/termul
Length of output: 2743
🏁 Script executed:
# Find CwdTracker to see how cwd is determined
sed -n '1,100p' ./src-tauri/src/trackers/cwd_tracker.rsRepository: gnoviawan/termul
Length of output: 3362
🏁 Script executed:
# Check the frontend file-explorer-store to see what entry.path looks like
cat ./src/renderer/stores/file-explorer-store.ts | head -150Repository: gnoviawan/termul
Length of output: 5021
🏁 Script executed:
# Look for entry.path usage in frontend
rg 'entry\.path' ./src/renderer/ -B 2 -A 2 | head -50Repository: gnoviawan/termul
Length of output: 3618
🏁 Script executed:
# Check if there's any path normalization in the frontend
rg -i 'normalize|replace.*\\|separator' ./src/renderer/stores/ -A 2 -B 2Repository: gnoviawan/termul
Length of output: 28671
🏁 Script executed:
# Look at the full context of how get_file_statuses is called in commands.rs
rg -B 10 'get_file_statuses' ./src-tauri/src/commands.rs | head -40Repository: gnoviawan/termul
Length of output: 486
🏁 Script executed:
# Search for where project_path comes from
rg 'project_path' ./src-tauri/src/commands.rs -B 3 -A 3 | head -60Repository: gnoviawan/termul
Length of output: 867
🏁 Script executed:
# Check if there's any filesystem API that reads directory entries
rg 'readDirectory' ./src-tauri/src/ -B 2 -A 5Repository: gnoviawan/termul
Length of output: 42
🏁 Script executed:
# Look for the filesystem API implementation to see what entry.path looks like
find ./src-tauri/src -name "*.rs" -type f | xargs grep -l 'entry\.path\|entry\["path"\]' | head -5Repository: gnoviawan/termul
Length of output: 42
🏁 Script executed:
# Check git-file-status-store to see how paths are matched
cat ./src/renderer/stores/git-file-status-store.tsRepository: gnoviawan/termul
Length of output: 2818
🏁 Script executed:
# Check the filesystem API implementation
find ./src-tauri/src -name "*.rs" -type f | xargs grep -l 'DirectoryEntry\|readDirectory' | head -5Repository: gnoviawan/termul
Length of output: 42
🏁 Script executed:
# Look at the complete Tauri command for filesystem operations
rg 'pub async fn.*read.*directory\|pub async fn.*get.*file' ./src-tauri/src/commands.rs -A 10 | head -60Repository: gnoviawan/termul
Length of output: 42
🏁 Script executed:
# Check what the filesystem API module returns
fd -e rs -name '*filesystem*' ./src-tauri/src/Repository: gnoviawan/termul
Length of output: 291
🏁 Script executed:
# Look at the filesystem types
rg 'DirectoryEntry' ./src-tauri/src/ -B 2 -A 5Repository: gnoviawan/termul
Length of output: 42
🏁 Script executed:
# Confirm how paths flow from backend through API
rg 'GitFileStatusEntry' ./src-tauri/src/commands.rs -B 5 -A 5Repository: gnoviawan/termul
Length of output: 671
🏁 Script executed:
# Check how project_path flows into get_file_statuses
# and whether it's already normalized somewhere before reaching git_tracker
rg 'project_git_file_statuses\|project_path' ./src-tauri/src/ -B 5 -A 2Repository: gnoviawan/termul
Length of output: 42
🏁 Script executed:
# Check if there's any normalization in the Tauri/IPC layer
rg -i 'normalize|to_string_lossy|canonicalize' ./src-tauri/src/commands.rs | head -20Repository: gnoviawan/termul
Length of output: 42
🏁 Script executed:
# Look at the full git_tracker.rs implementation to see all path handling
sed -n '930,1000p' ./src-tauri/src/trackers/git_tracker.rsRepository: gnoviawan/termul
Length of output: 2749
🏁 Script executed:
# Check how paths are constructed and if they're normalized anywhere
rg 'abs_path' ./src-tauri/src/trackers/git_tracker.rs -B 2 -A 2Repository: gnoviawan/termul
Length of output: 346
🏁 Script executed:
# Test: on the frontend, see if entry.path uses forward slashes in split
cat ./src/renderer/stores/git-file-status-store.ts | grep -A 5 -B 5 'split.*/'Repository: gnoviawan/termul
Length of output: 336
🏁 Script executed:
# Check get_file_diff for the same path issue
sed -n '977,1010p' ./src-tauri/src/trackers/git_tracker.rsRepository: gnoviawan/termul
Length of output: 1490
🏁 Script executed:
# Look for any normalization of project_path before it reaches git_tracker
rg 'project_git_file_statuses' ./src -B 10 | head -40Repository: gnoviawan/termul
Length of output: 735
🏁 Script executed:
# Check on what cwd value is expected - is it system native or normalized?
rg 'get_cwd\|detect_cwd' ./src-tauri/src/trackers/cwd_tracker.rs -A 15 | head -50Repository: gnoviawan/termul
Length of output: 42
🏁 Script executed:
# Look at complete get_file_diff to see full scope of path issue
sed -n '977,1030p' ./src-tauri/src/trackers/git_tracker.rsRepository: gnoviawan/termul
Length of output: 2055
🏁 Script executed:
# Verify git outputs forward slashes on Windows
# Check the actual git command structure
sed -n '898,925p' ./src-tauri/src/trackers/git_tracker.rsRepository: gnoviawan/termul
Length of output: 1203
🏁 Script executed:
# Check if CwdTracker returns system-native paths (backslashes on Windows)
sed -n '60,100p' ./src-tauri/src/trackers/cwd_tracker.rsRepository: gnoviawan/termul
Length of output: 1543
🏁 Script executed:
# Check if there's any path normalization happening before project_path is used
rg 'project_path' ./src-tauri/src/commands.rs -B 10 -A 10Repository: gnoviawan/termul
Length of output: 1248
🏁 Script executed:
# Verify git truly outputs forward slashes on Windows by checking git documentation
# or looking at test cases in the code
rg -i 'git.*status.*porcelain\|windows.*path\|forward.*slash' ./src-tauri/src/trackers/git_tracker.rsRepository: gnoviawan/termul
Length of output: 42
🏁 Script executed:
# Check what the filesystem API (Tauri's built-in) returns on Windows
# Look for any Tauri API calls
rg -i 'tauri.*fs\|read.*dir' ./src-tauri/src/commands.rs | head -20Repository: gnoviawan/termul
Length of output: 42
🏁 Script executed:
# Verify if project_path is the same path that the frontend normalizes
rg 'normalizePath' ./src/renderer -A 2 -B 2 | head -40Repository: gnoviawan/termul
Length of output: 2890
🏁 Script executed:
# Check if project_path parameter is the frontend-normalized path or system path
# Look at where project_git_file_statuses is called from
rg 'project_git_file_statuses' ./src/renderer -B 5 -A 2Repository: gnoviawan/termul
Length of output: 535
🏁 Script executed:
# Double-check: verify git status --porcelain always outputs forward slashes
# by looking for any test or documentation
rg -i 'porcelain\|git.*status' ./src-tauri/src/trackers/git_tracker.rs | head -10Repository: gnoviawan/termul
Length of output: 42
🏁 Script executed:
# Check where projectPath comes from in the frontend before calling project_git_file_statuses
rg 'projectGitFileStatuses\|project_git_file_statuses' ./src/renderer -B 10 | head -60Repository: gnoviawan/termul
Length of output: 42
🏁 Script executed:
# Look at how the project path is determined/stored
fd -type f -name "*.ts" -o -name "*.tsx" ./src/renderer | xargs grep -l 'projectPath\|project.*path' | head -5Repository: gnoviawan/termul
Length of output: 231
🏁 Script executed:
# Search for projectGitFileStatuses usage
rg 'projectGitFileStatuses' ./src/renderer/ -B 15 -A 5Repository: gnoviawan/termul
Length of output: 3829
🏁 Script executed:
# Look at the git API file
cat ./src/renderer/lib/tauri-git-api.tsRepository: gnoviawan/termul
Length of output: 1291
🏁 Script executed:
# Find where startGitFileStatusPolling is called to see what projectPath is
rg 'startGitFileStatusPolling' ./src/renderer -B 5 -A 5Repository: gnoviawan/termul
Length of output: 3074
🏁 Script executed:
# Check if projectPath comes from a normalized source
rg 'startGitFileStatusPolling\|projectPath' ./src/renderer/components -B 10 -A 2 | head -100Repository: gnoviawan/termul
Length of output: 42
🏁 Script executed:
# Check if there are any other sources of cwd or project_path that might not be normalized
rg 'get_file_statuses\|get_file_diff' ./src-tauri/src -B 5 | grep -E 'get_file_|cwd|project'Repository: gnoviawan/termul
Length of output: 42
🏁 Script executed:
# Specifically check what cwd sources exist in git_tracker calls
sed -n '1,100p' ./src-tauri/src/commands.rsRepository: gnoviawan/termul
Length of output: 2743
🏁 Script executed:
# Final verification: look at all callers of GitTracker methods
rg 'GitTracker::' ./src-tauri/src/ -B 3 -A 1Repository: gnoviawan/termul
Length of output: 4713
Normalize path separators in git status path construction on Windows.
On Windows, cwd may contain backslashes while git's porcelain output uses forward slashes (e.g., src/main.rs). Although the frontend currently normalizes paths before sending them to the backend, the backend code should not rely on this precondition. The simple concatenation format!("{}/{}", cwd.trim_end_matches('/'), file_path) could produce a mixed-separator path if cwd contains backslashes, breaking path matching downstream.
Add separator normalization after joining to make the code defensive and self-contained:
Proposed fix
let abs_path = if file_path.starts_with('/') {
file_path.to_string()
} else {
- format!("{}/{}", cwd.trim_end_matches('/'), file_path)
+ format!("{}/{}", cwd.trim_end_matches('/').trim_end_matches('\\'), file_path)
+ .replace('\\', "/")
};The same normalization should be applied in get_file_diff() at line 983-984 where cwd is used for path stripping.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| let abs_path = if file_path.starts_with('/') { | |
| file_path.to_string() | |
| } else { | |
| format!("{}/{}", cwd.trim_end_matches('/'), file_path) | |
| }; | |
| let abs_path = if file_path.starts_with('/') { | |
| file_path.to_string() | |
| } else { | |
| format!("{}/{}", cwd.trim_end_matches('/').trim_end_matches('\\'), file_path) | |
| .replace('\\', "/") | |
| }; |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src-tauri/src/trackers/git_tracker.rs` around lines 932 - 936, The
constructed absolute path in git_tracker.rs (variable abs_path built from cwd
and file_path) can mix backslashes from cwd with forward slashes from git
output; after joining (in the block that sets abs_path) normalize the path
separators to forward slashes (or use Path/PathBuf normalization) so the
resulting string uses '/' consistently; apply the same normalization where cwd
is used in get_file_diff() (the cwd-based path strip at lines around
get_file_diff) to ensure downstream path matching works on Windows.
| let (status, is_staged) = match (index_status, work_tree_status) { | ||
| // Untracked | ||
| ('?', '?') => (GitFileStatus::Untracked, false), | ||
| // Conflicted (unmerged) | ||
| ('U', _) | (_, 'U') | ('D', 'D') | ('A', 'A') => { | ||
| (GitFileStatus::Conflicted, true) | ||
| } | ||
| // Renamed | ||
| ('R', _) => (GitFileStatus::Renamed, true), | ||
| // Copied | ||
| ('C', _) => (GitFileStatus::Added, true), | ||
| // Both index and worktree changed | ||
| ('M', 'M') => (GitFileStatus::Modified, false), | ||
| // Only index changed (staged) | ||
| ('M', ' ') | ('A', ' ') | ('D', ' ') => { | ||
| let s = match index_status { | ||
| 'A' => GitFileStatus::Added, | ||
| 'D' => GitFileStatus::Deleted, | ||
| _ => GitFileStatus::Modified, | ||
| }; | ||
| (s, true) | ||
| } | ||
| // Only worktree changed | ||
| (' ', 'M') => (GitFileStatus::Modified, false), | ||
| (' ', 'D') => (GitFileStatus::Deleted, false), | ||
| // Fallback | ||
| _ => continue, | ||
| }; |
There was a problem hiding this comment.
Missing XY status combinations leave common file states untracked.
The match block falls to _ => continue for several valid and common combinations:
| XY | Scenario | Result |
|---|---|---|
('A', 'M') |
File staged with git add, then modified again in the working tree |
Silently skipped |
('M', 'D') |
Staged modification, then the file is deleted in the working tree | Silently skipped |
('A', 'D') |
Staged new file then deleted from disk | Silently skipped |
('A', 'M') in particular is extremely common: create a new file, git add it, then edit it again before committing.
🛠️ Proposed additions to the match block
// Only worktree changed
(' ', 'M') => (GitFileStatus::Modified, false),
(' ', 'D') => (GitFileStatus::Deleted, false),
+ // Staged + additional worktree change
+ ('A', 'M') => (GitFileStatus::Added, false), // staged add + modified
+ ('M', 'D') => (GitFileStatus::Modified, false), // staged modification + deleted
+ ('A', 'D') => (GitFileStatus::Added, false), // staged add + deleted
+ ('D', 'M') => (GitFileStatus::Deleted, false), // staged delete + modified
// Fallback
_ => continue,🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src-tauri/src/trackers/git_tracker.rs` around lines 939 - 966, The match over
(index_status, work_tree_status) in the git status parsing currently falls
through for common combos like ('A','M'), ('M','D') and ('A','D'); add explicit
arms in that match (referencing index_status, work_tree_status and the
GitFileStatus enum) so those states are not skipped: map ('A','M') ->
(GitFileStatus::Modified, true) (file staged then changed in worktree), map
('M','D') -> (GitFileStatus::Deleted, true) (staged change with worktree
deletion), and map ('A','D') -> (GitFileStatus::Deleted, true) (staged add then
removed from disk); insert these arms alongside the other patterns in the
existing match block to ensure these common states are handled.
| pub fn get_file_diff(cwd: &str, file_path: &str) -> String { | ||
| let relative = file_path.strip_prefix(cwd).unwrap_or(file_path); | ||
| let relative = relative.trim_start_matches('/'); |
There was a problem hiding this comment.
get_file_diff path stripping is fragile under trailing-slash cwd.
str::strip_prefix is a byte-exact string comparison. If cwd arrives with a trailing slash (e.g., "/home/user/project/") while file_path starts with "/home/user/project/src/...", strip_prefix returns None, the fallback gives the full absolute path, and trim_start_matches('/') produces "home/user/project/src/file.rs". Passed to git diff --, this relative path won't resolve correctly from the repo root, yielding a silent empty diff.
🛠️ Proposed fix
- let relative = file_path.strip_prefix(cwd).unwrap_or(file_path);
- let relative = relative.trim_start_matches('/');
+ let cwd_norm = cwd.trim_end_matches('/').trim_end_matches('\\');
+ let relative = file_path
+ .strip_prefix(cwd_norm)
+ .unwrap_or(file_path)
+ .trim_start_matches('/')
+ .trim_start_matches('\\');📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| pub fn get_file_diff(cwd: &str, file_path: &str) -> String { | |
| let relative = file_path.strip_prefix(cwd).unwrap_or(file_path); | |
| let relative = relative.trim_start_matches('/'); | |
| pub fn get_file_diff(cwd: &str, file_path: &str) -> String { | |
| let cwd_norm = cwd.trim_end_matches('/').trim_end_matches('\\'); | |
| let relative = file_path | |
| .strip_prefix(cwd_norm) | |
| .unwrap_or(file_path) | |
| .trim_start_matches('/') | |
| .trim_start_matches('\\'); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src-tauri/src/trackers/git_tracker.rs` around lines 982 - 984, get_file_diff
currently uses string strip_prefix and trim_start_matches which breaks when cwd
has a trailing slash; change get_file_diff to work with std::path::Path (use
Path::new for cwd and file_path), normalize both paths (trim trailing separators
or canonicalize/clean) and compute the relative path with Path::strip_prefix (or
attempt strip_prefix both with and without a trailing separator) so the
resulting relative path is correct before passing to git; update the code paths
that reference cwd and file_path to use the Path-based relative result and
convert it to a String (e.g., via to_string_lossy) for the git diff call.
| const hasGitChanges = !!gitStatus | ||
|
|
||
| // Show toolbar if: markdown file (always) OR any file with git changes | ||
| const showToolbar = isMarkdownFile || hasGitChanges | ||
|
|
||
| return ( | ||
| <div className="w-full h-full flex flex-col"> | ||
| {isMarkdownFile && ( | ||
| {showToolbar && ( | ||
| <EditorToolbar | ||
| viewMode={fileState.viewMode} | ||
| onToggleViewMode={handleToggleViewMode} | ||
| filePath={filePath} | ||
| hasGitChanges={hasGitChanges} | ||
| showDiff={showDiff} | ||
| onToggleDiff={handleToggleDiff} | ||
| /> | ||
| )} |
There was a problem hiding this comment.
Toolbar disappears mid-diff for non-markdown files, trapping the user.
When a non-markdown file is in diff mode and all git changes are committed (or the next poll clears gitStatus), hasGitChanges becomes false, showToolbar becomes false, and the Diff toggle button vanishes — leaving the user stuck viewing "No changes to display" with no UI to exit diff mode. Markdown files are unaffected because isMarkdownFile always keeps the toolbar.
Fix: include showDiff in the toolbar guard so the toggle remains reachable while diff mode is active:
🛠️ Proposed fix
- const showToolbar = isMarkdownFile || hasGitChanges
+ const showToolbar = isMarkdownFile || hasGitChanges || showDiff📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const hasGitChanges = !!gitStatus | |
| // Show toolbar if: markdown file (always) OR any file with git changes | |
| const showToolbar = isMarkdownFile || hasGitChanges | |
| return ( | |
| <div className="w-full h-full flex flex-col"> | |
| {isMarkdownFile && ( | |
| {showToolbar && ( | |
| <EditorToolbar | |
| viewMode={fileState.viewMode} | |
| onToggleViewMode={handleToggleViewMode} | |
| filePath={filePath} | |
| hasGitChanges={hasGitChanges} | |
| showDiff={showDiff} | |
| onToggleDiff={handleToggleDiff} | |
| /> | |
| )} | |
| const hasGitChanges = !!gitStatus | |
| // Show toolbar if: markdown file (always) OR any file with git changes OR diff is open | |
| const showToolbar = isMarkdownFile || hasGitChanges || showDiff | |
| return ( | |
| <div className="w-full h-full flex flex-col"> | |
| {showToolbar && ( | |
| <EditorToolbar | |
| viewMode={fileState.viewMode} | |
| onToggleViewMode={handleToggleViewMode} | |
| filePath={filePath} | |
| hasGitChanges={hasGitChanges} | |
| showDiff={showDiff} | |
| onToggleDiff={handleToggleDiff} | |
| /> | |
| )} |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/renderer/components/editor/EditorPanel.tsx` around lines 108 - 124, The
toolbar hiding logic can remove the Diff toggle while in diff mode; update the
showToolbar condition to keep the toolbar visible when showDiff is true.
Specifically, change the computed showToolbar (currently using isMarkdownFile ||
hasGitChanges) to include showDiff so that the EditorToolbar (props: viewMode
via fileState.viewMode, onToggleViewMode handleToggleViewMode, filePath,
hasGitChanges, showDiff, onToggleDiff handleToggleDiff) remains rendered
whenever showDiff is active, ensuring users can exit diff mode even if
gitStatus/hasGitChanges becomes false.
| const result = await invoke<{ | ||
| success: boolean | ||
| data?: GitFileStatusEntry[] | ||
| error?: string | ||
| }>('project_git_file_statuses', { projectPath }) | ||
|
|
||
| if (result.success && result.data) { | ||
| return result.data | ||
| } | ||
|
|
||
| return [] | ||
| } |
There was a problem hiding this comment.
Add try/catch around every invoke call to prevent unhandled rejections during polling.
invoke throws on IPC-level failures (command not registered, serialization error, Tauri channel closed). Because projectGitFileStatuses is called on a 5-second poll loop, every tick will surface an unhandled rejection and pollute the console (or worse, crash an error boundary) until the condition is resolved. The existing !isTauriContext() guard does not cover runtime IPC errors.
🛡️ Proposed fix — wrap both `invoke` calls
async function projectGitFileStatuses(
projectPath: string,
): Promise<GitFileStatusEntry[]> {
if (!isTauriContext()) return []
- const result = await invoke<{
- success: boolean
- data?: GitFileStatusEntry[]
- error?: string
- }>('project_git_file_statuses', { projectPath })
-
- if (result.success && result.data) {
- return result.data
+ try {
+ const result = await invoke<{
+ success: boolean
+ data?: GitFileStatusEntry[]
+ error?: string
+ }>('project_git_file_statuses', { projectPath })
+
+ if (result.success && result.data) {
+ return result.data
+ }
+ } catch (e) {
+ console.error('[gitApi] project_git_file_statuses failed:', e)
}
return []
} async function projectGitDiffFile(
projectPath: string,
filePath: string,
): Promise<string> {
if (!isTauriContext()) return ''
- const result = await invoke<{
- success: boolean
- data?: string
- error?: string
- }>('project_git_diff_file', { projectPath, filePath })
-
- if (result.success && result.data) {
- return result.data
+ try {
+ const result = await invoke<{
+ success: boolean
+ data?: string
+ error?: string
+ }>('project_git_diff_file', { projectPath, filePath })
+
+ if (result.success && result.data) {
+ return result.data
+ }
+ } catch (e) {
+ console.error('[gitApi] project_git_diff_file failed:', e)
}
return ''
}Also applies to: 54-64
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/renderer/lib/tauri-git-api.ts` around lines 30 - 41, Wrap each call to
invoke in project_git_file_statuses (and the other invoke at lines 54-64) inside
a try/catch so IPC-level exceptions don't propagate during polling: catch any
thrown error from invoke('project_git_file_statuses', { projectPath }) (and the
other invoke call), log or swallow the error as appropriate, and return an empty
array or fallback value when an exception occurs; update the functions in
src/renderer/lib/tauri-git-api.ts that call invoke to use try/catch around the
invoke(...) calls (refer to the invoke call site and the function that returns
result.data) so unhandled rejections are prevented.
| const parts = entry.path.split('/') | ||
| // Walk up parent directories | ||
| for (let i = parts.length - 1; i >= 2; i--) { | ||
| const dirPath = parts.slice(0, i).join('/') | ||
| const existing = map.get(dirPath) | ||
|
|
||
| if (!existing) { | ||
| map.set(dirPath, { path: dirPath, status: entry.status, isStaged: entry.isStaged }) | ||
| } else if ((priority[entry.status] ?? 0) > (priority[existing.status] ?? 0)) { | ||
| // Higher-priority status wins | ||
| map.set(dirPath, { path: dirPath, status: entry.status, isStaged: entry.isStaged }) | ||
| } | ||
| } |
There was a problem hiding this comment.
Path split with '/' only will mis-propagate folder status on Windows.
entry.path.split('/') assumes forward-slash-only paths. If the backend produces mixed separators (e.g., C:\Users\user\project/src/file.ts — see the related issue in git_tracker.rs), the split leaves the entire C:\Users\user\project segment as one part at index 0, so only C:\Users\user\project/src gets a propagated status entry. The project root folder never receives any status colour on Windows.
This issue is downstream of the path-separator construction in get_file_statuses; normalizing to forward slashes on the Rust side (suggested above) will fix this automatically. No independent change needed here unless the Rust fix is deferred.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/renderer/stores/git-file-status-store.ts` around lines 46 - 58, The code
uses entry.path.split('/') when walking parent directories which fails for
Windows paths with backslashes; normalize entry.path to use forward slashes (or
split on both separators) before computing parts so folder statuses propagate
correctly (update the place that constructs parts from entry.path used in the
parent-directory walk, which currently calls entry.path.split('/'), and ensure
the rest of the logic that updates map entries and compares against priority and
isStaged remains unchanged).
New Features
1. Middle-click to close tab
onAuxClickhandler on all tab types (terminal, editor, browser) — standard browser behavior, no need to find the X button.2. Git file status colors in explorer
project_git_file_statusesparsesgit status --porcelaininto per-file entries3. Git diff preview
project_git_diff_filereturns unified diff for a single fileFiles Changed
src-tauri/src/commands.rs— 2 new commandssrc-tauri/src/lib.rs— command registrationsrc-tauri/src/trackers/git_tracker.rs— file status parser + diff methodsrc/renderer/lib/tauri-git-api.ts— new API modulesrc/renderer/stores/git-file-status-store.ts— new store with folder propagationsrc/renderer/components/file-explorer/FileExplorer.tsx— polling setupsrc/renderer/components/file-explorer/FileTreeNode.tsx— color indicatorssrc/renderer/components/editor/DiffViewer.tsx— new diff viewer componentsrc/renderer/components/editor/EditorToolbar.tsx— diff toggle buttonsrc/renderer/components/editor/EditorPanel.tsx— diff state managementsrc/renderer/components/workspace/WorkspaceTabBar.tsx— middle-click handlersTesting
Summary by CodeRabbit
Release Notes