Skip to content

Commit 2d2e9a6

Browse files
committed
feat: add working directory support with file watching in desktop app
Implement real-time working directory comparison with automatic diff refresh on file changes. - Add WORKDIR sentinel support in Rust backend using libgit2 diff_tree_to_workdir - Implement file watcher using notify crate with 500ms debounce - List tracked files from filesystem respecting .gitignore patterns - Display working directory as "My Working Directory" in branch dropdowns - Auto-refresh diff when files change (debounced 300ms on frontend) - Auto-refresh branch list when git refs change - Validate to prevent WORKDIR vs WORKDIR comparison - Clean up watcher resources on repo close Dependencies: ignore 0.4, notify 6.1, notify-debouncer-full 0.3
1 parent b7ea6ac commit 2d2e9a6

8 files changed

Lines changed: 425 additions & 47 deletions

File tree

apps/desktop/src-tauri/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ tauri-plugin-clipboard-manager = "2.0"
2020
serde = { version = "1", features = ["derive"] }
2121
serde_json = "1"
2222
git2 = "0.19"
23+
ignore = "0.4"
24+
notify = "6.1"
25+
notify-debouncer-full = "0.3"
2326

2427
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
2528
tauri-plugin-cli = "2.0"

apps/desktop/src-tauri/src/git.rs

Lines changed: 165 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ use git2::Repository;
22
use serde::{Deserialize, Serialize};
33
use std::path::Path;
44

5+
const WORKDIR_SENTINEL: &str = "__WORKDIR__";
6+
57
#[derive(Debug, Serialize, Deserialize)]
68
#[serde(rename_all = "camelCase")]
79
pub struct LoadRepoResult {
@@ -56,7 +58,7 @@ pub fn open_repo(path: &str) -> Result<LoadRepoResult, String> {
5658
let repo = Repository::open(path).map_err(|e| format!("Failed to open repository: {}", e))?;
5759

5860
// Get local branches only (avoid confusion with remote branches like origin/...)
59-
let branches = repo
61+
let branches: Vec<String> = repo
6062
.branches(Some(git2::BranchType::Local))
6163
.map_err(|e| format!("Failed to list branches: {}", e))?
6264
.filter_map(|branch_result| {
@@ -72,8 +74,12 @@ pub fn open_repo(path: &str) -> Result<LoadRepoResult, String> {
7274
.ok()
7375
.and_then(|head| head.shorthand().map(|s| s.to_string()));
7476

77+
// Prepend WORKDIR to branch list
78+
let mut all_branches = vec![WORKDIR_SENTINEL.to_string()];
79+
all_branches.extend(branches);
80+
7581
Ok(LoadRepoResult {
76-
branches,
82+
branches: all_branches,
7783
default_branch,
7884
})
7985
}
@@ -87,38 +93,73 @@ pub fn get_branches(path: &str) -> Result<LoadRepoResult, String> {
8793
pub fn git_diff(path: &str, base: &str, compare: &str) -> Result<DiffResult, String> {
8894
let repo = Repository::open(path).map_err(|e| format!("Failed to open repository: {}", e))?;
8995

90-
// Resolve base and compare to commits
91-
let base_commit = repo
92-
.revparse_single(base)
93-
.map_err(|e| format!("Failed to resolve base ref '{}': {}", base, e))?
94-
.peel_to_commit()
95-
.map_err(|e| format!("Failed to peel base to commit: {}", e))?;
96-
97-
let compare_commit = repo
98-
.revparse_single(compare)
99-
.map_err(|e| format!("Failed to resolve compare ref '{}': {}", compare, e))?
100-
.peel_to_commit()
101-
.map_err(|e| format!("Failed to peel compare to commit: {}", e))?;
102-
103-
// Get trees from commits
104-
let base_tree = base_commit
105-
.tree()
106-
.map_err(|e| format!("Failed to get base tree: {}", e))?;
107-
108-
let compare_tree = compare_commit
109-
.tree()
110-
.map_err(|e| format!("Failed to get compare tree: {}", e))?;
111-
112-
// Compute diff
113-
let mut diff = repo
114-
.diff_tree_to_tree(Some(&base_tree), Some(&compare_tree), None)
115-
.map_err(|e| format!("Failed to compute diff: {}", e))?;
96+
// Handle WORKDIR sentinel
97+
let mut diff = if compare == WORKDIR_SENTINEL {
98+
// Compare base tree to working directory
99+
let base_commit = repo
100+
.revparse_single(base)
101+
.map_err(|e| format!("Failed to resolve base ref '{}': {}", base, e))?
102+
.peel_to_commit()
103+
.map_err(|e| format!("Failed to peel base to commit: {}", e))?;
104+
105+
let base_tree = base_commit
106+
.tree()
107+
.map_err(|e| format!("Failed to get base tree: {}", e))?;
108+
109+
let mut opts = git2::DiffOptions::new();
110+
opts.include_untracked(false); // Only show tracked files
111+
112+
repo.diff_tree_to_workdir(Some(&base_tree), Some(&mut opts))
113+
.map_err(|e| format!("Failed to compute diff to workdir: {}", e))?
114+
} else if base == WORKDIR_SENTINEL {
115+
// Compare working directory to compare tree (reverse diff)
116+
let compare_commit = repo
117+
.revparse_single(compare)
118+
.map_err(|e| format!("Failed to resolve compare ref '{}': {}", compare, e))?
119+
.peel_to_commit()
120+
.map_err(|e| format!("Failed to peel compare to commit: {}", e))?;
121+
122+
let compare_tree = compare_commit
123+
.tree()
124+
.map_err(|e| format!("Failed to get compare tree: {}", e))?;
125+
126+
let mut opts = git2::DiffOptions::new();
127+
opts.include_untracked(false); // Only show tracked files
128+
129+
repo.diff_tree_to_workdir(Some(&compare_tree), Some(&mut opts))
130+
.map_err(|e| format!("Failed to compute diff to workdir: {}", e))?
131+
} else {
132+
// Normal tree-to-tree diff
133+
let base_commit = repo
134+
.revparse_single(base)
135+
.map_err(|e| format!("Failed to resolve base ref '{}': {}", base, e))?
136+
.peel_to_commit()
137+
.map_err(|e| format!("Failed to peel base to commit: {}", e))?;
138+
139+
let compare_commit = repo
140+
.revparse_single(compare)
141+
.map_err(|e| format!("Failed to resolve compare ref '{}': {}", compare, e))?
142+
.peel_to_commit()
143+
.map_err(|e| format!("Failed to peel compare to commit: {}", e))?;
144+
145+
let base_tree = base_commit
146+
.tree()
147+
.map_err(|e| format!("Failed to get base tree: {}", e))?;
148+
149+
let compare_tree = compare_commit
150+
.tree()
151+
.map_err(|e| format!("Failed to get compare tree: {}", e))?;
152+
153+
repo.diff_tree_to_tree(Some(&base_tree), Some(&compare_tree), None)
154+
.map_err(|e| format!("Failed to compute diff: {}", e))?
155+
};
116156

117157
// Enable rename and copy detection
118158
diff.find_similar(None)
119159
.map_err(|e| format!("Failed to find similar files: {}", e))?;
120160

121161
let mut files = Vec::new();
162+
let invert_changes = base == WORKDIR_SENTINEL; // Invert change types when base is WORKDIR
122163

123164
diff.foreach(
124165
&mut |delta, _progress| {
@@ -132,10 +173,18 @@ pub fn git_diff(path: &str, base: &str, compare: &str) -> Result<DiffResult, Str
132173

133174
let (path, change_type, stored_old_path) = match delta.status() {
134175
git2::Delta::Added => {
135-
(new_path.unwrap_or_default(), "add", None)
176+
if invert_changes {
177+
(new_path.unwrap_or_default(), "remove", None)
178+
} else {
179+
(new_path.unwrap_or_default(), "add", None)
180+
}
136181
},
137182
git2::Delta::Deleted => {
138-
(old_path.unwrap_or_default(), "remove", None)
183+
if invert_changes {
184+
(old_path.unwrap_or_default(), "add", None)
185+
} else {
186+
(old_path.unwrap_or_default(), "remove", None)
187+
}
139188
},
140189
git2::Delta::Modified => {
141190
(new_path.unwrap_or_default(), "modify", None)
@@ -170,8 +219,47 @@ pub fn git_diff(path: &str, base: &str, compare: &str) -> Result<DiffResult, Str
170219
Ok(DiffResult { files })
171220
}
172221

222+
/// List all files in working directory (respecting .gitignore)
223+
fn list_workdir_files(path: &str) -> Result<ListFilesResult, String> {
224+
use ignore::WalkBuilder;
225+
226+
let mut files = Vec::new();
227+
let walker = WalkBuilder::new(path)
228+
.hidden(false) // Show hidden files
229+
.git_ignore(true) // Respect .gitignore
230+
.git_exclude(true) // Respect .git/info/exclude
231+
.build();
232+
233+
for entry in walker {
234+
match entry {
235+
Ok(entry) => {
236+
if entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
237+
// Get path relative to repo root
238+
if let Ok(rel_path) = entry.path().strip_prefix(path) {
239+
let path_str = rel_path.to_string_lossy().to_string();
240+
// Exclude .git directory files
241+
if !path_str.starts_with(".git/") && !path_str.starts_with(".git\\") {
242+
// Normalize path separators to forward slashes
243+
let normalized = path_str.replace('\\', "/");
244+
files.push(normalized);
245+
}
246+
}
247+
}
248+
}
249+
Err(_) => continue, // Skip errors (permission issues, etc.)
250+
}
251+
}
252+
253+
Ok(ListFilesResult { files })
254+
}
255+
173256
/// List all files in a tree at a specific ref
174257
pub fn list_files(path: &str, ref_name: &str) -> Result<ListFilesResult, String> {
258+
// Handle WORKDIR sentinel
259+
if ref_name == WORKDIR_SENTINEL {
260+
return list_workdir_files(path);
261+
}
262+
175263
let repo = Repository::open(path).map_err(|e| format!("Failed to open repository: {}", e))?;
176264

177265
// Resolve ref to commit
@@ -263,7 +351,47 @@ pub fn resolve_ref(path: &str, ref_name: &str) -> Result<ResolveRefResult, Strin
263351

264352
/// Read file content at a specific ref
265353
pub fn read_file_blob(path: &str, ref_name: &str, file_path: &str) -> Result<ReadFileResult, String> {
266-
let repo = Repository::open(path).map_err(|e| format!("Failed to open repository: {}", e))?;
354+
// Handle WORKDIR sentinel - read from filesystem
355+
if ref_name == WORKDIR_SENTINEL {
356+
use std::fs;
357+
use std::path::PathBuf;
358+
359+
let full_path = PathBuf::from(path).join(file_path);
360+
361+
match fs::read(&full_path) {
362+
Ok(content) => {
363+
// Check if binary (scan first 8KB for null bytes)
364+
let check_len = content.len().min(8192);
365+
let is_binary = content[..check_len].iter().any(|&b| b == 0);
366+
367+
if is_binary {
368+
return Ok(ReadFileResult {
369+
binary: true,
370+
text: None,
371+
not_found: None,
372+
});
373+
}
374+
375+
// Convert to UTF-8 string (use lossy conversion for non-UTF8 files)
376+
let text = String::from_utf8_lossy(&content).into_owned();
377+
378+
Ok(ReadFileResult {
379+
binary: false,
380+
text: Some(text),
381+
not_found: None,
382+
})
383+
}
384+
Err(_) => {
385+
Ok(ReadFileResult {
386+
binary: false,
387+
text: None,
388+
not_found: Some(true),
389+
})
390+
}
391+
}
392+
} else {
393+
// Normal Git blob reading
394+
let repo = Repository::open(path).map_err(|e| format!("Failed to open repository: {}", e))?;
267395

268396
// Resolve ref to commit
269397
let commit = repo
@@ -323,11 +451,12 @@ pub fn read_file_blob(path: &str, ref_name: &str, file_path: &str) -> Result<Rea
323451
// Convert to UTF-8 string (use lossy conversion for non-UTF8 encodings like Latin-1)
324452
let text = String::from_utf8_lossy(content).into_owned();
325453

326-
Ok(ReadFileResult {
327-
binary: false,
328-
text: Some(text),
329-
not_found: None,
330-
})
454+
Ok(ReadFileResult {
455+
binary: false,
456+
text: Some(text),
457+
not_found: None,
458+
})
459+
}
331460
}
332461

333462
#[cfg(test)]

apps/desktop/src-tauri/src/lib.rs

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
#[cfg(debug_assertions)]
22
use tauri::Manager;
3+
use std::sync::Mutex;
34

45
mod git;
6+
mod watcher;
7+
8+
// Global watcher state
9+
static WATCHER: Mutex<Option<watcher::RepoWatcher>> = Mutex::new(None);
510

611
// Test command to verify Tauri is working
712
#[tauri::command]
@@ -11,8 +16,28 @@ fn greet(name: &str) -> String {
1116

1217
// Git operations commands
1318
#[tauri::command]
14-
fn open_repo(path: String) -> Result<git::LoadRepoResult, String> {
15-
git::open_repo(&path)
19+
fn open_repo(app: tauri::AppHandle, path: String) -> Result<git::LoadRepoResult, String> {
20+
let result = git::open_repo(&path)?;
21+
22+
// Stop existing watcher if any
23+
if let Ok(mut watcher) = WATCHER.lock() {
24+
if let Some(old_watcher) = watcher.take() {
25+
old_watcher.stop();
26+
}
27+
28+
// Start new watcher
29+
match watcher::RepoWatcher::new(app, path.clone()) {
30+
Ok(new_watcher) => {
31+
*watcher = Some(new_watcher);
32+
}
33+
Err(e) => {
34+
eprintln!("Warning: Failed to start file watcher: {}", e);
35+
// Continue without watcher - not a fatal error
36+
}
37+
}
38+
}
39+
40+
Ok(result)
1641
}
1742

1843
#[tauri::command]
@@ -45,6 +70,16 @@ fn resolve_ref(path: String, ref_name: String) -> Result<git::ResolveRefResult,
4570
git::resolve_ref(&path, &ref_name)
4671
}
4772

73+
#[tauri::command]
74+
fn close_repo() -> Result<(), String> {
75+
if let Ok(mut watcher) = WATCHER.lock() {
76+
if let Some(old_watcher) = watcher.take() {
77+
old_watcher.stop();
78+
}
79+
}
80+
Ok(())
81+
}
82+
4883
#[cfg_attr(mobile, tauri::mobile_entry_point)]
4984
pub fn run() {
5085
tauri::Builder::default()
@@ -59,7 +94,8 @@ pub fn run() {
5994
read_file_blob,
6095
list_files,
6196
list_files_with_oids,
62-
resolve_ref
97+
resolve_ref,
98+
close_repo
6399
])
64100
.setup(|_app| {
65101
#[cfg(debug_assertions)]

0 commit comments

Comments
 (0)