Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,10 @@ jobs:
url: ${{ steps.deployment.outputs.page_url }}

steps:
- name: Checkout correct version
- name: Checkout main branch
uses: actions/checkout@v4
with:
ref: v${{ needs.release.outputs.version }}
ref: main
fetch-depth: 0

- name: Setup Node.js
Expand Down
56 changes: 39 additions & 17 deletions apps/desktop/src-tauri/src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ pub struct LoadRepoResult {
pub struct DiffFile {
pub path: String,
#[serde(rename = "type")]
pub change_type: String, // "modify" | "add" | "remove"
pub change_type: String, // "modify" | "add" | "remove" | "rename" | "copy"
#[serde(skip_serializing_if = "Option::is_none")]
pub old_path: Option<String>, // For renames and copies
}

#[derive(Debug, Serialize, Deserialize)]
Expand Down Expand Up @@ -108,32 +110,53 @@ pub fn git_diff(path: &str, base: &str, compare: &str) -> Result<DiffResult, Str
.map_err(|e| format!("Failed to get compare tree: {}", e))?;

// Compute diff
let diff = repo
let mut diff = repo
.diff_tree_to_tree(Some(&base_tree), Some(&compare_tree), None)
.map_err(|e| format!("Failed to compute diff: {}", e))?;

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

let mut files = Vec::new();

diff.foreach(
&mut |delta, _progress| {
let path = delta.new_file().path()
.or_else(|| delta.old_file().path())
let new_path = delta.new_file().path()
.and_then(|p| p.to_str())
.map(|s| s.to_string());

let old_path = delta.old_file().path()
.and_then(|p| p.to_str())
.unwrap_or("")
.to_string();

let change_type = match delta.status() {
git2::Delta::Added => "add",
git2::Delta::Deleted => "remove",
git2::Delta::Modified => "modify",
git2::Delta::Renamed => "modify",
git2::Delta::Copied => "add",
_ => "modify",
.map(|s| s.to_string());

let (path, change_type, stored_old_path) = match delta.status() {
git2::Delta::Added => {
(new_path.unwrap_or_default(), "add", None)
},
git2::Delta::Deleted => {
(old_path.unwrap_or_default(), "remove", None)
},
git2::Delta::Modified => {
(new_path.unwrap_or_default(), "modify", None)
},
git2::Delta::Renamed => {
// For renames, store the new path as primary and old path separately
(new_path.clone().unwrap_or_default(), "rename", old_path)
Comment on lines +143 to +145
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle new rename/copy statuses in UI diff flow

The diff now emits "rename" (and "copy") statuses, but the front-end only recognizes FileDiffStatus = 'modify' | 'add' | 'remove' | 'unchanged' (see packages/core/src/types.ts) and useFileTree casts diff types into that union. As a result, renamed/copied files will fall through the “unchanged” handling in copyAllSelected/token counting, which fetches content at the new path from the base ref and often yields empty output, so users won’t see the rename/copy diff and may think the file is unchanged. This only shows up when the diff includes renames/copies, but it means the new statuses aren’t actually supported end‑to‑end.

Useful? React with 👍 / 👎.

},
git2::Delta::Copied => {
// For copies, store the new path as primary and old path separately
(new_path.clone().unwrap_or_default(), "copy", old_path)
},
_ => {
(new_path.or(old_path).unwrap_or_default(), "modify", None)
},
};

files.push(DiffFile {
path,
change_type: change_type.to_string(),
old_path: stored_old_path,
});

true
Expand Down Expand Up @@ -297,9 +320,8 @@ pub fn read_file_blob(path: &str, ref_name: &str, file_path: &str) -> Result<Rea
});
}

// Convert to UTF-8 string
let text = String::from_utf8(content.to_vec())
.map_err(|_| "Failed to decode file as UTF-8".to_string())?;
// Convert to UTF-8 string (use lossy conversion for non-UTF8 encodings like Latin-1)
let text = String::from_utf8_lossy(content).into_owned();

Ok(ReadFileResult {
binary: false,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/hooks/useGitRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ export function useGitRepository(setAppStatus?: (s: AppStatus) => void) {
}, [currentDir, loadRepoFromHandle])

const resetRepo = useCallback(() => {
// Note: dispose() clears the repo path but the service instance remains
// valid and can be reused. This is by design for the singleton pattern.
gitClient.dispose()
setRepoStatus({ state: 'idle' })
setCurrentDir(null)
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/services/TauriGitService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,11 @@ export class TauriGitService implements GitService {
return result
}

/**
* Dispose of resources by clearing the repo path.
* Note: This service instance remains valid and can be reused after dispose().
* Call loadRepo() again to load a new repository.
*/
dispose(): void {
this.repoPath = null
}
Expand Down
101 changes: 70 additions & 31 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,34 @@
import { logError } from './utils/logger'
import { debounce } from './utils/debounce'

// Prompt templates (moved outside component to avoid recreation on every render)
const PROMPT_TEMPLATES = [
{
id: 'branch-summary',
label: 'Summarize branch diff',
content:
'You are an expert engineer. Summarize the changes between the selected branches and explain the impact of the modifications.',
},
{
id: 'wd-review',
label: 'Review working directory changes',
content:
'You are a code reviewer. Review the current working directory diff for potential issues, bugs, or improvements.',
},
{
id: 'test-plan',
label: 'Suggest tests for diff',
content:
'You are a QA engineer. Based on the provided diff, propose relevant unit or integration tests to cover the changes.',
},
{
id: 'release-notes',
label: 'Draft release notes',
content:
'You are a technical writer. Craft concise release notes that describe the user-facing effects of the diff.',
},
]

function App() {
const [appStatus, setAppStatus] = useState<AppStatus>({ state: 'IDLE' })
// note: we will temporarily set task='tokens' while counting, see effect below
Expand Down Expand Up @@ -51,6 +79,9 @@
compare?: { binary: boolean; text: string | null; notFound?: boolean }
} | null>(null)

// Track preview request IDs to prevent race conditions
const previewRequestIdRef = useRef(0)

const [theme, setTheme] = useState<'light' | 'dark' | null>(() => {
try {
const saved = localStorage.getItem('gc.theme')
Expand Down Expand Up @@ -113,7 +144,7 @@
useEffect(() => {
const shouldPrefetch = currentDir === null && !exampleText && !exampleLoading
if (shouldPrefetch) void loadExampleIfNeeded()
}, [currentDir])

Check warning on line 147 in apps/web/src/App.tsx

View workflow job for this annotation

GitHub Actions / web-build

React Hook useEffect has missing dependencies: 'exampleLoading', 'exampleText', and 'loadExampleIfNeeded'. Either include them or remove the dependency array

Check warning on line 147 in apps/web/src/App.tsx

View workflow job for this annotation

GitHub Actions / web-build

React Hook useEffect has missing dependencies: 'exampleLoading', 'exampleText', and 'loadExampleIfNeeded'. Either include them or remove the dependency array

// --- Column resizer state & handlers ---
const uiHasResizer = currentDir !== null
Expand Down Expand Up @@ -154,7 +185,7 @@
const current = Number((getComputedStyle(appEl).getPropertyValue('--left-col') || '').replace('px','')) || minLeft
handle.setAttribute('aria-valuenow', String(current))
handle.setAttribute('aria-label', 'Resize panels')
} catch {}

Check failure on line 188 in apps/web/src/App.tsx

View workflow job for this annotation

GitHub Actions / web-build

Empty block statement

Check failure on line 188 in apps/web/src/App.tsx

View workflow job for this annotation

GitHub Actions / web-build

Empty block statement

const applyLeft = (px: number) => {
const clamped = Math.min(Math.max(px, minLeft), maxLeft)
Expand All @@ -164,7 +195,7 @@
} catch (e) {
logError('leftColSave', e)
}
try { handle.setAttribute('aria-valuenow', String(clamped)) } catch {}

Check failure on line 198 in apps/web/src/App.tsx

View workflow job for this annotation

GitHub Actions / web-build

Empty block statement

Check failure on line 198 in apps/web/src/App.tsx

View workflow job for this annotation

GitHub Actions / web-build

Empty block statement
}

let dragging = false
Expand Down Expand Up @@ -226,7 +257,7 @@
const nextMax = computeMaxLeft()
if (nextMax !== maxLeft) {
maxLeft = nextMax
try { handle.setAttribute('aria-valuemax', String(maxLeft)) } catch {}

Check failure on line 260 in apps/web/src/App.tsx

View workflow job for this annotation

GitHub Actions / web-build

Empty block statement

Check failure on line 260 in apps/web/src/App.tsx

View workflow job for this annotation

GitHub Actions / web-build

Empty block statement
const curr = Number((getComputedStyle(appEl).getPropertyValue('--left-col') || '').replace('px','')) || minLeft
applyLeft(curr) // re-clamp
}
Expand Down Expand Up @@ -270,32 +301,6 @@
return () => { cancelled = true }
}, [userInstructions])

const PROMPT_TEMPLATES = [
{
id: 'branch-summary',
label: 'Summarize branch diff',
content:
'You are an expert engineer. Summarize the changes between the selected branches and explain the impact of the modifications.',
},
{
id: 'wd-review',
label: 'Review working directory changes',
content:
'You are a code reviewer. Review the current working directory diff for potential issues, bugs, or improvements.',
},
{
id: 'test-plan',
label: 'Suggest tests for diff',
content:
'You are a QA engineer. Based on the provided diff, propose relevant unit or integration tests to cover the changes.',
},
{
id: 'release-notes',
label: 'Draft release notes',
content:
'You are a technical writer. Craft concise release notes that describe the user-facing effects of the diff.',
},
]
const [templateId, setTemplateId] = useState<string>('')

// Model selection: fetched dynamically; derive token limit from the selected model
Expand Down Expand Up @@ -351,7 +356,7 @@
const saved = localStorage.getItem('gc.includeBinaryAsPaths')
if (saved === '0' || saved === 'false') return false
if (saved === '1' || saved === 'true') return true
} catch {}

Check failure on line 359 in apps/web/src/App.tsx

View workflow job for this annotation

GitHub Actions / web-build

Empty block statement

Check failure on line 359 in apps/web/src/App.tsx

View workflow job for this annotation

GitHub Actions / web-build

Empty block statement
return true
})
const includeBinaryAsPathsRef = useRef<boolean>(includeBinaryAsPaths)
Expand All @@ -361,7 +366,7 @@
useEffect(() => {
try {
localStorage.setItem('gc.includeBinaryAsPaths', includeBinaryAsPaths ? '1' : '0')
} catch {}

Check failure on line 369 in apps/web/src/App.tsx

View workflow job for this annotation

GitHub Actions / web-build

Empty block statement

Check failure on line 369 in apps/web/src/App.tsx

View workflow job for this annotation

GitHub Actions / web-build

Empty block statement
}, [includeBinaryAsPaths])
const [diffContextLines, setDiffContextLines] = useState<number>(3)
// Immediate UI value used for copy; debounced value used for token recomputations
Expand All @@ -370,6 +375,14 @@
const diffRangeRef = useRef<HTMLInputElement | null>(null)
const MAX_CONTEXT = 999
const debouncedSetDiffContextLines = useMemo(() => debounce(setDiffContextLines, 250), [])

// Cancel debounced function on unmount to avoid setState after unmount
useEffect(() => {
return () => {
debouncedSetDiffContextLines.cancel()
}
}, [debouncedSetDiffContextLines])

// Collapsible User Instructions
const [instructionsOpen, setInstructionsOpen] = useState<boolean>(true)

Expand Down Expand Up @@ -583,6 +596,10 @@
setNotif('Binary file preview is not supported.')
return
}

// Increment request ID to track this specific preview request
const requestId = ++previewRequestIdRef.current

try {
const toFetchBase = status !== 'add'
const toFetchCompare = status !== 'remove'
Expand All @@ -592,9 +609,14 @@
toFetchCompare && compareBranch ? gitClient.readFile(compareBranch, path) : Promise.resolve(undefined),
])

// Check if this request is still the latest one (prevent race condition)
if (requestId !== previewRequestIdRef.current) {
return // A newer preview request has been made, ignore this result
}

// If worker reports binary, bail out as well
const baseBin = (baseRes as any)?.binary

Check failure on line 618 in apps/web/src/App.tsx

View workflow job for this annotation

GitHub Actions / web-build

Unexpected any. Specify a different type

Check failure on line 618 in apps/web/src/App.tsx

View workflow job for this annotation

GitHub Actions / web-build

Unexpected any. Specify a different type
const compareBin = (compareRes as any)?.binary

Check failure on line 619 in apps/web/src/App.tsx

View workflow job for this annotation

GitHub Actions / web-build

Unexpected any. Specify a different type

Check failure on line 619 in apps/web/src/App.tsx

View workflow job for this annotation

GitHub Actions / web-build

Unexpected any. Specify a different type
if (baseBin || compareBin) {
setNotif('Binary file preview is not supported.')
return
Expand All @@ -607,8 +629,11 @@
})
setPreviewOpen(true)
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e))
setNotif(`Failed to read file content: ${err.message}`)
// Only show error if this is still the latest request
if (requestId === previewRequestIdRef.current) {
const err = e instanceof Error ? e : new Error(String(e))
setNotif(`Failed to read file content: ${err.message}`)
}
}
}

Expand Down Expand Up @@ -903,7 +928,7 @@
message: `${msg} ${overallPercent}%`,
progress: overallPercent,
})
} catch {}

Check failure on line 931 in apps/web/src/App.tsx

View workflow job for this annotation

GitHub Actions / web-build

Empty block statement

Check failure on line 931 in apps/web/src/App.tsx

View workflow job for this annotation

GitHub Actions / web-build

Empty block statement
}
} else {
if (
Expand All @@ -915,7 +940,7 @@
setAppStatus({ state: 'READY', message: 'Token counts updated.' })
try {
console.info('[app-status]', { state: 'READY', message: 'Token counts updated.' })
} catch {}

Check failure on line 943 in apps/web/src/App.tsx

View workflow job for this annotation

GitHub Actions / web-build

Empty block statement

Check failure on line 943 in apps/web/src/App.tsx

View workflow job for this annotation

GitHub Actions / web-build

Empty block statement
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
Expand Down Expand Up @@ -1151,9 +1176,24 @@
value={templateId}
onChange={(e) => {
const id = e.target.value
setTemplateId(id)
const tmpl = PROMPT_TEMPLATES.find((t) => t.id === id)
if (tmpl) setUserInstructions(tmpl.content)
if (tmpl) {
// If user has custom instructions, confirm before overwriting
const hasCustomText = userInstructions.trim() && userInstructions.trim() !== tmpl.content
if (hasCustomText) {
const confirmed = window.confirm(
'This will replace your current instructions. Continue?'
)
if (!confirmed) {
// Reset select to previous value
return
}
}
setTemplateId(id)
setUserInstructions(tmpl.content)
} else {
setTemplateId(id)
}
}}
style={{ flexGrow: 1 }}
>
Expand Down Expand Up @@ -1297,7 +1337,6 @@

<div className="panel-section">
<SelectedFilesPanel
key={`sel-${selectedPaths.size}`}
selectedPaths={selectedPaths}
statusByPath={statusByPath}
onUnselect={(path) => toggleSelect(path)}
Expand Down
29 changes: 26 additions & 3 deletions apps/web/src/hooks/useFileTree.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react'
import { useCallback, useState, useRef } from 'react'
import type { GitWorkerClient } from '../utils/gitWorkerClient'
import type { AppStatus } from '../types/appStatus'
import { isBinaryPath, LARGE_REPO_FILE_THRESHOLD, type FileDiffStatus, type FileTreeNode } from '@gitcontext/core'
Expand All @@ -18,6 +18,9 @@
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set())
const [isComputing, setIsComputing] = useState<boolean>(false)

// Track diff computation request IDs to prevent race conditions
const diffRequestIdRef = useRef(0)

const buildTreeFromPaths = useCallback((allPaths: string[], diffMap: Map<string, FileDiffStatus>): { tree: FileTreeNode; statusByPath: Map<string, FileDiffStatus> } => {
const root: FileTreeNode = { name: '', path: '', type: 'dir', children: [] }
const dirMap = new Map<string, FileTreeNode>()
Expand Down Expand Up @@ -85,21 +88,35 @@
setExpandedPaths(new Set())
return
}

// Increment request ID to track this specific diff computation
const requestId = ++diffRequestIdRef.current
setIsComputing(true)

try {
setAppStatus?.({ state: 'LOADING', task: 'diff', message: 'Computing file differences…', progress: 25 })
try { console.info('[app-status]', { state: 'LOADING', task: 'diff', message: 'Computing file differences…', progress: 25 }) } catch {}
setProgress?.({ message: 'Computing file differences…', percent: 25 })

const res = await gitClient.diff(baseBranch, compareBranch)

// Check if this request is still the latest one (prevent race condition)
if (requestId !== diffRequestIdRef.current) {
return // A newer diff request has been made, ignore this result
}

setDiffFiles(res.files)

setProgress?.({ message: 'Fetching file list…', percent: 50 })
setAppStatus?.({ state: 'LOADING', task: 'diff', message: 'Fetching file list…', progress: 50 })
try { console.info('[app-status]', { state: 'LOADING', task: 'diff', message: 'Fetching file list…', progress: 50 }) } catch {}
const baseList = await gitClient.listFiles(baseBranch)
const compareList = await gitClient.listFiles(compareBranch)

// Check again after async operations
if (requestId !== diffRequestIdRef.current) {
return
}
const diffMap = new Map<string, FileDiffStatus>()
for (const f of res.files) diffMap.set(f.path, f.type as FileDiffStatus)
// Build union from both sides to keep unchanged files present on either side
Expand All @@ -108,6 +125,12 @@
setAppStatus?.({ state: 'LOADING', task: 'diff', message: 'Building file tree…', progress: 75 })
try { console.info('[app-status]', { state: 'LOADING', task: 'diff', message: 'Building file tree…', progress: 75 }) } catch {}
const { tree, statusByPath: statusMap } = buildTreeFromPaths(Array.from(union), diffMap)

// Final check before committing all state updates
if (requestId !== diffRequestIdRef.current) {
return
}

setFileTree(tree)
setStatusByPath(statusMap)

Expand Down Expand Up @@ -150,7 +173,7 @@
setIsComputing(false)
}
},
[buildTreeFromPaths],

Check warning on line 176 in apps/web/src/hooks/useFileTree.ts

View workflow job for this annotation

GitHub Actions / web-build

React Hook useCallback has a missing dependency: 'setAppStatus'. Either include it or remove the dependency array. If 'setAppStatus' changes too often, find the parent component that defines it and wrap that definition in useCallback

Check warning on line 176 in apps/web/src/hooks/useFileTree.ts

View workflow job for this annotation

GitHub Actions / web-build

React Hook useCallback has a missing dependency: 'setAppStatus'. Either include it or remove the dependency array. If 'setAppStatus' changes too often, find the parent component that defines it and wrap that definition in useCallback
)

const toggleExpand = useCallback((path: string) => {
Expand Down
Loading
Loading