Skip to content

Commit 4c954b9

Browse files
milispclaude
andcommitted
feat: add git status integration and diff viewer
Add comprehensive git status viewing functionality with a dedicated Git tab in the file tree panel, including diff viewing capabilities for modified files. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 2093f98 commit 4c954b9

File tree

6 files changed

+326
-13
lines changed

6 files changed

+326
-13
lines changed
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
use serde::{Deserialize, Serialize};
2+
use std::path::Path;
3+
use std::process::Command;
4+
5+
#[derive(Debug, Serialize, Deserialize)]
6+
pub struct GitStatus {
7+
pub staged: Vec<String>,
8+
pub modified: Vec<String>,
9+
pub untracked: Vec<String>,
10+
pub deleted: Vec<String>,
11+
pub renamed: Vec<String>,
12+
pub conflicted: Vec<String>,
13+
}
14+
15+
#[tauri::command]
16+
pub async fn get_git_status(directory: String) -> Result<GitStatus, String> {
17+
let expanded_path = if directory.starts_with("~/") {
18+
let home = dirs::home_dir()
19+
.ok_or_else(|| "Cannot find home directory".to_string())?;
20+
home.join(&directory[2..])
21+
} else {
22+
Path::new(&directory).to_path_buf()
23+
};
24+
25+
let output = Command::new("git")
26+
.args(["status", "--porcelain"])
27+
.current_dir(&expanded_path)
28+
.output()
29+
.map_err(|e| format!("Failed to execute git command: {}", e))?;
30+
31+
if !output.status.success() {
32+
return Err("Not a git repository or git command failed".to_string());
33+
}
34+
35+
let mut git_status = GitStatus {
36+
staged: Vec::new(),
37+
modified: Vec::new(),
38+
untracked: Vec::new(),
39+
deleted: Vec::new(),
40+
renamed: Vec::new(),
41+
conflicted: Vec::new(),
42+
};
43+
44+
let status_output = String::from_utf8_lossy(&output.stdout);
45+
for line in status_output.lines() {
46+
if line.len() < 3 {
47+
continue;
48+
}
49+
50+
let status_code = &line[..2];
51+
let file_path = line[3..].to_string();
52+
53+
match status_code {
54+
"??" => git_status.untracked.push(file_path),
55+
"A " | "AM" => git_status.staged.push(file_path),
56+
"M " => git_status.staged.push(file_path),
57+
" M" => git_status.modified.push(file_path),
58+
"MM" => git_status.modified.push(file_path),
59+
" D" => git_status.deleted.push(file_path),
60+
"D " => git_status.staged.push(file_path),
61+
"R " | "RM" => git_status.renamed.push(file_path),
62+
"UU" => git_status.conflicted.push(file_path),
63+
_ => {}
64+
}
65+
}
66+
67+
Ok(git_status)
68+
}

src-tauri/src/filesystem/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ pub mod file_io;
44
pub mod file_parsers;
55
pub mod file_types;
66
pub mod git_diff;
7+
pub mod git_status;

src-tauri/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ use filesystem::{
2323
file_io::{read_file, write_file},
2424
file_parsers::{csv::read_csv_content, pdf::read_pdf_content, xlsx::read_xlsx_content},
2525
git_diff::get_git_file_diff,
26+
git_status::get_git_status,
2627
};
2728
use state::CodexState;
2829

@@ -69,6 +70,7 @@ pub fn run() {
6970
read_csv_content,
7071
read_xlsx_content,
7172
get_git_file_diff,
73+
get_git_status,
7274
read_codex_config,
7375
get_project_name,
7476
read_mcp_servers,
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { useState, useEffect } from "react";
2+
import { invoke } from "@tauri-apps/api/core";
3+
import { useFolderStore } from "@/stores/FolderStore";
4+
import { RefreshCw, GitBranch, FileText } from "lucide-react";
5+
import { Button } from "@/components/ui/button";
6+
7+
interface GitStatus {
8+
staged: string[];
9+
modified: string[];
10+
untracked: string[];
11+
deleted: string[];
12+
renamed: string[];
13+
conflicted: string[];
14+
}
15+
16+
interface FileWithStatus {
17+
path: string;
18+
status: string;
19+
statusColor: string;
20+
}
21+
22+
interface GitStatusViewProps {
23+
currentFolder?: string;
24+
onDiffClick?: (path: string) => void;
25+
}
26+
27+
export function GitStatusView({ currentFolder, onDiffClick }: GitStatusViewProps) {
28+
const [gitStatus, setGitStatus] = useState<GitStatus | null>(null);
29+
const [loading, setLoading] = useState(false);
30+
const [error, setError] = useState<string | null>(null);
31+
const { currentFolder: storeFolder } = useFolderStore();
32+
33+
const loadGitStatus = async (path?: string) => {
34+
setLoading(true);
35+
setError(null);
36+
37+
try {
38+
const targetPath = path || currentFolder || storeFolder;
39+
if (!targetPath) {
40+
setError("No folder selected");
41+
return;
42+
}
43+
44+
const result = await invoke<GitStatus>("get_git_status", {
45+
directory: targetPath,
46+
});
47+
setGitStatus(result);
48+
} catch (err) {
49+
setError(err as string);
50+
} finally {
51+
setLoading(false);
52+
}
53+
};
54+
55+
useEffect(() => {
56+
loadGitStatus();
57+
}, [currentFolder, storeFolder]);
58+
59+
if (loading) {
60+
return (
61+
<div className="p-4 text-center text-gray-500 flex items-center justify-center gap-2">
62+
<RefreshCw className="w-4 h-4 animate-spin" />
63+
Loading git status...
64+
</div>
65+
);
66+
}
67+
68+
if (error) {
69+
return (
70+
<div className="p-4 text-center text-gray-500">
71+
<GitBranch className="w-8 h-8 mx-auto mb-2 opacity-50" />
72+
<p className="text-sm">{error}</p>
73+
<Button
74+
onClick={() => loadGitStatus()}
75+
variant="ghost"
76+
size="sm"
77+
className="mt-2"
78+
>
79+
<RefreshCw className="w-4 h-4 mr-1" />
80+
Retry
81+
</Button>
82+
</div>
83+
);
84+
}
85+
86+
if (!gitStatus) {
87+
return (
88+
<div className="p-4 text-center text-gray-500">
89+
<GitBranch className="w-8 h-8 mx-auto mb-2 opacity-50" />
90+
<p className="text-sm">Not a git repository</p>
91+
</div>
92+
);
93+
}
94+
95+
const allFiles: FileWithStatus[] = [
96+
...gitStatus.conflicted.map(path => ({ path, status: '!', statusColor: 'text-red-700' })),
97+
...gitStatus.staged.map(path => ({ path, status: 'A', statusColor: 'text-green-600' })),
98+
...gitStatus.modified.map(path => ({ path, status: 'M', statusColor: 'text-yellow-600' })),
99+
...gitStatus.deleted.map(path => ({ path, status: 'D', statusColor: 'text-red-500' })),
100+
...gitStatus.renamed.map(path => ({ path, status: 'R', statusColor: 'text-blue-500' })),
101+
...gitStatus.untracked.map(path => ({ path, status: 'U', statusColor: 'text-green-500' }))
102+
];
103+
104+
if (allFiles.length === 0) {
105+
return (
106+
<div className="p-4 text-center text-gray-500">
107+
<GitBranch className="w-8 h-8 mx-auto mb-2 opacity-50" />
108+
<p className="text-sm">Working tree clean</p>
109+
<Button
110+
onClick={() => loadGitStatus()}
111+
variant="ghost"
112+
size="sm"
113+
className="mt-2"
114+
>
115+
<RefreshCw className="w-4 h-4 mr-1" />
116+
Refresh
117+
</Button>
118+
</div>
119+
);
120+
}
121+
122+
return (
123+
<div className="w-full h-full flex flex-col">
124+
<div className="flex items-center justify-between px-3 border-b border-gray-200">
125+
<div className="flex items-center gap-2">
126+
<h3 className="font-medium text-sm">Source control</h3>
127+
</div>
128+
<Button
129+
onClick={() => loadGitStatus()}
130+
variant="ghost"
131+
size="sm"
132+
className="p-1 h-auto"
133+
>
134+
<RefreshCw className="w-3 h-3" />
135+
</Button>
136+
</div>
137+
138+
<div className="flex-1 overflow-y-auto p-3">
139+
{allFiles.map((file, index) => {
140+
const handleClick = () => {
141+
onDiffClick?.(file.path);
142+
};
143+
144+
return (
145+
<div
146+
key={index}
147+
className="flex items-center justify-between text-sm py-1 px-2 hover:bg-gray-100 rounded cursor-pointer"
148+
onClick={handleClick}
149+
title="Click to view diff"
150+
>
151+
<div className="flex items-center gap-2 flex-1 min-w-0">
152+
<FileText className="w-3 h-3 flex-shrink-0" />
153+
<span className="truncate">{file.path}</span>
154+
</div>
155+
<span className={`${file.statusColor} font-mono text-xs font-semibold ml-2 flex-shrink-0`}>
156+
{file.status}
157+
</span>
158+
</div>
159+
);
160+
})}
161+
</div>
162+
</div>
163+
);
164+
}

src/pages/chat.tsx

Lines changed: 84 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,15 @@ import { useLayoutStore } from "@/stores/layoutStore";
44
import { useFolderStore } from "@/stores/FolderStore";
55
import { FileTree } from "@/components/filetree/FileTreeView";
66
import { FileViewer } from "@/components/filetree/FileViewer";
7+
import { GitStatusView } from "@/components/filetree/GitStatusView";
8+
import { DiffViewer } from "@/components/filetree/DiffViewer";
79
import { useState } from "react";
810
import { ConfigDialog } from "@/components/dialogs/ConfigDialog";
911
import { AppToolbar } from "@/components/layout/AppToolbar";
1012
import { useConversationStore } from "@/stores/ConversationStore";
13+
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
14+
import { invoke } from "@tauri-apps/api/core";
15+
import { GitBranch } from "lucide-react";
1116

1217
export default function ChatPage() {
1318

@@ -28,29 +33,99 @@ export default function ChatPage() {
2833

2934
const { currentFolder } = useFolderStore();
3035
const [isConfigOpen, setIsConfigOpen] = useState(false);
36+
const [diffFile, setDiffFile] = useState<{ original: string; current: string; fileName: string } | null>(null);
37+
38+
const handleDiffClick = async (filePath: string) => {
39+
try {
40+
console.log('handleDiffClick called with:', filePath);
41+
console.log('currentFolder:', currentFolder);
42+
43+
// Try with currentFolder first, then fallback to direct path
44+
const fullPath = currentFolder ? `${currentFolder}/${filePath}` : filePath;
45+
console.log('Trying full path:', fullPath);
46+
47+
const result = await invoke<{ original_content: string; current_content: string; has_changes: boolean }>("get_git_file_diff", {
48+
filePath: fullPath
49+
});
50+
51+
if (result.has_changes) {
52+
// Clear selected file to avoid conflicts
53+
closeFile();
54+
// Set diff file and ensure panel is visible
55+
setDiffFile({
56+
original: result.original_content,
57+
current: result.current_content,
58+
fileName: filePath
59+
});
60+
}
61+
} catch (error) {
62+
console.error("Failed to get diff:", error);
63+
console.error("Tried path:", currentFolder ? `${currentFolder}/${filePath}` : filePath);
64+
}
65+
};
3166

3267
// No auto-initialization - let user start conversations manually
3368

3469
return (
3570
<div className="h-full flex overflow-hidden">
36-
{/* Left Panel - File Tree */}
71+
{/* Left Panel - File Tree and Git Status */}
3772
{showFileTree && (
3873
<div className="w-64 border-r h-full flex-shrink-0">
39-
<FileTree
40-
currentFolder={currentFolder || undefined}
41-
onFileClick={openFile}
42-
/>
74+
<Tabs defaultValue="files" className="h-full flex flex-col">
75+
<TabsList className="grid w-full grid-cols-2">
76+
<TabsTrigger value="files">Files</TabsTrigger>
77+
<TabsTrigger value="git">
78+
<GitBranch size={14} className="mr-1.5" />
79+
Git
80+
</TabsTrigger>
81+
</TabsList>
82+
<TabsContent value="files" className="flex-1 overflow-hidden mt-0">
83+
<FileTree
84+
currentFolder={currentFolder || undefined}
85+
onFileClick={(path) => {
86+
console.log('ChatPage: opening file from FileTree', path);
87+
setDiffFile(null); // Clear any existing diff view
88+
openFile(path);
89+
}}
90+
/>
91+
</TabsContent>
92+
<TabsContent value="git" className="flex-1 overflow-hidden mt-0">
93+
<GitStatusView
94+
currentFolder={currentFolder || undefined}
95+
onDiffClick={handleDiffClick}
96+
/>
97+
</TabsContent>
98+
</Tabs>
4399
</div>
44100
)}
45101

46102
{/* Main Content Area */}
47103
<div className="flex-1 min-h-0 h-full flex min-w-0 overflow-hidden">
48-
{/* Middle Panel - FileViewer */}
49-
{showFilePanel && selectedFile && (
104+
{/* Middle Panel - FileViewer or DiffViewer */}
105+
{(showFilePanel && selectedFile) || diffFile ? (
50106
<div className="flex-1 min-w-0 border-r overflow-hidden">
51-
<FileViewer filePath={selectedFile} onClose={closeFile} />
107+
{diffFile ? (
108+
<div className="h-full flex flex-col">
109+
<div className="p-2 border-b bg-gray-50 flex items-center justify-between">
110+
<span className="text-sm font-medium">Diff: {diffFile.fileName}</span>
111+
<button
112+
onClick={() => setDiffFile(null)}
113+
className="text-gray-500 hover:text-gray-700"
114+
>
115+
×
116+
</button>
117+
</div>
118+
<DiffViewer
119+
original={diffFile.original}
120+
current={diffFile.current}
121+
fileName={diffFile.fileName}
122+
/>
123+
</div>
124+
) : selectedFile ? (
125+
<FileViewer filePath={selectedFile} onClose={closeFile} />
126+
) : null}
52127
</div>
53-
)}
128+
) : null}
54129

55130
{/* Right Panel - Chat/Notes */}
56131
<div className="flex flex-col flex-1 min-w-0 overflow-hidden">

src/stores/layoutStore.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,13 @@ export const useLayoutStore = create<LayoutState>()(
7070
toggleChatPane: () => set((state) => ({ showChatPane: !state.showChatPane })),
7171
toggleFileTree: () => set((state) => ({ showFileTree: !state.showFileTree })),
7272

73-
openFile: (filePath) => set({
74-
selectedFile: filePath,
75-
showFilePanel: true
76-
}),
73+
openFile: (filePath) => {
74+
console.log('layoutStore: openFile called with', filePath);
75+
set({
76+
selectedFile: filePath,
77+
showFilePanel: true
78+
});
79+
},
7780

7881
closeFile: () => set({
7982
selectedFile: null,

0 commit comments

Comments
 (0)