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
39 changes: 38 additions & 1 deletion src/main/services/git.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,44 @@ export async function getWorkingDiff(repoPath: string): Promise<string> {
// Show both staged and unstaged changes
const unstaged = await g.diff()
const staged = await g.diff(['--cached'])
return [staged, unstaged].filter(Boolean).join('\n')
// Untracked files are not picked up by `git diff`; synthesize an addition
// diff for each so they render in the branch-compare view.
const status = await g.status()
const untrackedDiffs: string[] = []
for (const filePath of status.not_added) {
const synth = await synthesizeAddedFileDiff(repoPath, filePath)
if (synth) untrackedDiffs.push(synth)
}
return [staged, unstaged, ...untrackedDiffs].filter(Boolean).join('\n')
}

async function synthesizeAddedFileDiff(repoPath: string, filePath: string): Promise<string | null> {
let buf: Buffer
try {
buf = await readFile(join(repoPath, filePath))
} catch {
return null
}
// Skip binary files — produce a stub note instead of garbled text.
const isBinary = buf.includes(0)
const header =
`diff --git a/${filePath} b/${filePath}\n` +
`new file mode 100644\n` +
`index 0000000..0000000\n` +
`--- /dev/null\n` +
`+++ b/${filePath}\n`
if (isBinary) {
return header + `Binary files /dev/null and b/${filePath} differ\n`
}
const text = buf.toString('utf8')
if (text.length === 0) return header + `@@ -0,0 +0,0 @@\n`
const hadTrailingNewline = text.endsWith('\n')
const body = hadTrailingNewline ? text.slice(0, -1) : text
const lines = body.split('\n')
const hunk = `@@ -0,0 +1,${lines.length} @@\n`
const added = lines.map((l) => `+${l}`).join('\n')
const tail = hadTrailingNewline ? '\n' : '\n\\ No newline at end of file\n'
return header + hunk + added + tail
}

// ── Branch comparison (PR preview) ─────────────────────────────────────────
Expand Down
250 changes: 189 additions & 61 deletions src/renderer/components/git/ChangedFiles.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useCallback } from 'react'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { useGitStore, WORKING_CHANGES_HASH } from '../../stores/gitStore'
import { useToastStore } from '../../stores/toastStore'
import { ListBox, ListItem } from '../ui/ListBox'
Expand Down Expand Up @@ -29,10 +29,138 @@ export function ChangedFiles({ repoPath }: Props) {

const isWorking = selectedCommitHash === WORKING_CHANGES_HASH

// Multi-select state — only meaningful for the working-changes pseudo-commit.
// `extra` holds additionally-selected file paths (active selection is
// selectedFilePath in the store). `anchor` is the pivot for shift-range.
const [extra, setExtra] = useState<Set<string>>(() => new Set())
const [anchor, setAnchor] = useState<string | null>(null)

// Reset multi-select when leaving working changes or when file list changes
// such that selected paths no longer exist.
useEffect(() => {
if (!isWorking) {
if (extra.size > 0) setExtra(new Set())
if (anchor !== null) setAnchor(null)
return
}
const present = new Set(changedFiles.map((f) => f.filePath))
let changed = false
const next = new Set<string>()
for (const p of extra) {
if (present.has(p)) next.add(p)
else changed = true
}
if (changed) setExtra(next)
if (anchor && !present.has(anchor)) setAnchor(null)
}, [isWorking, changedFiles, extra, anchor])

const selectedSet = useMemo(() => {
const s = new Set(extra)
if (selectedFilePath) s.add(selectedFilePath)
return s
}, [extra, selectedFilePath])

const handleClick = useCallback(
(e: React.MouseEvent, file: FileDiff) => {
if (!selectedCommitHash) return
if (!isWorking) {
// Single-select for non-working commits
selectFile(repoPath, selectedCommitHash, file.filePath)
return
}
const cmd = e.metaKey || e.ctrlKey
const shift = e.shiftKey
if (shift && (anchor || selectedFilePath)) {
const start = anchor ?? selectedFilePath!
const startIdx = changedFiles.findIndex((f) => f.filePath === start)
const endIdx = changedFiles.findIndex((f) => f.filePath === file.filePath)
if (startIdx === -1 || endIdx === -1) {
selectFile(repoPath, selectedCommitHash, file.filePath)
setExtra(new Set())
setAnchor(file.filePath)
return
}
const [lo, hi] = startIdx < endIdx ? [startIdx, endIdx] : [endIdx, startIdx]
const next = new Set<string>()
for (let i = lo; i <= hi; i++) next.add(changedFiles[i].filePath)
next.delete(file.filePath) // active path lives in selectedFilePath
setExtra(next)
selectFile(repoPath, selectedCommitHash, file.filePath)
return
}
if (cmd) {
// Toggle this file in the selection.
const isCurrentlySelected = selectedSet.has(file.filePath)
if (isCurrentlySelected) {
// Deselect — but keep at least one file as the active diff target.
if (file.filePath === selectedFilePath) {
// Promote one of `extra` to active (or clear if none).
const promoted = extra.values().next().value as string | undefined
if (promoted) {
const nextExtra = new Set(extra)
nextExtra.delete(promoted)
setExtra(nextExtra)
selectFile(repoPath, selectedCommitHash, promoted)
} else {
// Nothing else selected — leave the click as a no-op.
}
} else {
const nextExtra = new Set(extra)
nextExtra.delete(file.filePath)
setExtra(nextExtra)
}
} else {
// Add to selection. Make this the active diff target and push the
// previously-active path into `extra`.
const nextExtra = new Set(extra)
if (selectedFilePath && selectedFilePath !== file.filePath) nextExtra.add(selectedFilePath)
nextExtra.delete(file.filePath)
setExtra(nextExtra)
selectFile(repoPath, selectedCommitHash, file.filePath)
}
setAnchor(file.filePath)
return
}
// Plain click — single select
setExtra(new Set())
setAnchor(file.filePath)
selectFile(repoPath, selectedCommitHash, file.filePath)
},
[repoPath, isWorking, selectedCommitHash, selectedFilePath, anchor, changedFiles, extra, selectedSet, selectFile]
)

const buildItems = useCallback(
(file: FileDiff): ContextMenuItem[] => {
const { addToast } = useToastStore.getState()
const refresh = () => loadWorkingFiles(repoPath)

// If the right-clicked file is part of the multi-selection, target all
// selected files; otherwise treat the click as a single-file action and
// make sure the click target becomes the active selection.
const targets = isWorking && selectedSet.has(file.filePath)
? Array.from(selectedSet)
: [file.filePath]

const isMulti = targets.length > 1
const suffix = isMulti ? ` (${targets.length} files)` : ''

const runAll = async (
action: (path: string) => Promise<void>,
successMsg?: (n: number) => string
) => {
const errors: string[] = []
for (const path of targets) {
try {
await action(path)
} catch (err) {
errors.push(`${path}: ${err instanceof Error ? err.message : String(err)}`)
}
}
await refresh()
if (errors.length > 0) addToast('error', errors.join('\n'))
else if (successMsg) addToast('success', successMsg(targets.length))
}

const items: ContextMenuItem[] = [
{
label: 'Open',
Expand All @@ -44,66 +172,60 @@ export function ChangedFiles({ repoPath }: Props) {
onClick: () => window.api.git.revealFile(`${repoPath}/${file.filePath}`),
},
{
label: 'Copy path',
onClick: () => navigator.clipboard.writeText(file.filePath),
label: isMulti ? `Copy ${targets.length} paths` : 'Copy path',
onClick: () => navigator.clipboard.writeText(targets.join('\n')),
separatorAfter: isWorking,
},
]
if (isWorking) {
items.push(
{
label: 'Stage file',
onClick: async () => {
try {
await window.api.git.stageFile(repoPath, file.filePath)
await refresh()
} catch (err) {
addToast('error', err instanceof Error ? err.message : String(err))
}
},
label: `Stage file${suffix}`,
onClick: () => runAll((p) => window.api.git.stageFile(repoPath, p)),
},
{
label: 'Unstage file',
onClick: async () => {
try {
await window.api.git.unstageFile(repoPath, file.filePath)
await refresh()
} catch (err) {
addToast('error', err instanceof Error ? err.message : String(err))
}
},
label: `Unstage file${suffix}`,
onClick: () => runAll((p) => window.api.git.unstageFile(repoPath, p)),
},
{
label: 'Stash this file',
onClick: async () => {
try {
await window.api.git.stashFile(repoPath, file.filePath)
await refresh()
addToast('success', `Stashed ${file.filePath}`)
} catch (err) {
addToast('error', err instanceof Error ? err.message : String(err))
}
},
label: `Stash this file${suffix}`,
onClick: () =>
runAll(
(p) => window.api.git.stashFile(repoPath, p),
(n) => (n === 1 ? `Stashed ${targets[0]}` : `Stashed ${n} files`)
),
separatorAfter: true,
},
{
label: 'Discard changes',
label: `Discard changes${suffix}`,
variant: 'danger',
onClick: async () => {
if (!confirm(`Discard changes to ${file.filePath}? This cannot be undone.`)) return
try {
await window.api.git.discardFile(repoPath, file.filePath)
await refresh()
} catch (err) {
addToast('error', err instanceof Error ? err.message : String(err))
}
onClick: () => {
const msg = isMulti
? `Discard changes to ${targets.length} files? This cannot be undone.`
: `Discard changes to ${targets[0]}? This cannot be undone.`
if (!confirm(msg)) return
runAll((p) => window.api.git.discardFile(repoPath, p))
},
}
)
}
return items
},
[repoPath, selectedCommitHash, isWorking, selectFile, loadWorkingFiles]
[repoPath, selectedCommitHash, isWorking, selectedSet, selectFile, loadWorkingFiles]
)

const handleContextMenu = useCallback(
(e: React.MouseEvent, file: FileDiff) => {
// If right-clicking a file outside the current selection, replace the
// selection with just that file before opening the menu.
if (isWorking && !selectedSet.has(file.filePath) && selectedCommitHash) {
setExtra(new Set())
setAnchor(file.filePath)
selectFile(repoPath, selectedCommitHash, file.filePath)
}
onContextMenu(e, buildItems(file))
},
[isWorking, selectedSet, selectedCommitHash, repoPath, selectFile, onContextMenu, buildItems]
)

if (!selectedCommitHash) {
Expand All @@ -123,27 +245,33 @@ export function ChangedFiles({ repoPath }: Props) {
selectFile(repoPath, selectedCommitHash, changedFiles[index].filePath)
}
>
{changedFiles.map((file) => (
<ListItem
key={file.filePath}
selected={file.filePath === selectedFilePath}
onClick={() => selectFile(repoPath, selectedCommitHash, file.filePath)}
onContextMenu={(e) => onContextMenu(e, buildItems(file))}
className="text-xs flex items-center gap-2"
style={{ padding: '6px 12px' }}
>
<span className={`font-mono font-bold ${STATUS_COLORS[file.status] || ''}`}>
{STATUS_LABELS[file.status] || '?'}
</span>
<span className="truncate">{file.filePath}</span>
{(file.insertions > 0 || file.deletions > 0) && (
<span className="ml-auto flex gap-1 text-[10px]">
{file.insertions > 0 && <span className="text-success">+{file.insertions}</span>}
{file.deletions > 0 && <span className="text-danger">-{file.deletions}</span>}
{changedFiles.map((file) => {
const inSelection = selectedSet.has(file.filePath)
const isActive = file.filePath === selectedFilePath
return (
<ListItem
key={file.filePath}
selected={isActive}
onClick={(e) => handleClick(e, file)}
onContextMenu={(e) => handleContextMenu(e, file)}
className={`text-xs flex items-center gap-2 ${
inSelection && !isActive ? 'bg-accent/5 text-text' : ''
}`}
style={{ padding: '6px 12px' }}
>
<span className={`font-mono font-bold ${STATUS_COLORS[file.status] || ''}`}>
{STATUS_LABELS[file.status] || '?'}
</span>
)}
</ListItem>
))}
<span className="truncate">{file.filePath}</span>
{(file.insertions > 0 || file.deletions > 0) && (
<span className="ml-auto flex gap-1 text-[10px]">
{file.insertions > 0 && <span className="text-success">+{file.insertions}</span>}
{file.deletions > 0 && <span className="text-danger">-{file.deletions}</span>}
</span>
)}
</ListItem>
)
})}
</ListBox>
{menu}
</>
Expand Down
Loading
Loading