Skip to content

Commit 0e3f482

Browse files
committed
feat(desktop): add batch file selection from clipboard in file tree
1 parent 0436333 commit 0e3f482

5 files changed

Lines changed: 190 additions & 1 deletion

File tree

apps/desktop/src-tauri/tauri.conf.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"dialog:allow-open",
5050
"fs:allow-read-dir",
5151
"fs:allow-read-file",
52+
"clipboard-manager:allow-read-text",
5253
"clipboard-manager:allow-write-text"
5354
]
5455
}

apps/desktop/src/App.tsx

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import './App.css'
33
import { ChevronsDown, ChevronsUp, CheckSquare, Square, Sun, Moon, Folder, FolderGit2, ListChecks, Copy, ArrowLeftRight } from 'lucide-react'
44
import { FileTreeView, PreviewModal, GitHubStarIconButton, BugIconButton } from '@gitcontext/ui'
55
import { type FileDiffStatus, isBinaryPath, MAX_CONCURRENT_READS } from '@gitcontext/core'
6-
import { writeText } from '@tauri-apps/plugin-clipboard-manager'
6+
import { readText, writeText } from '@tauri-apps/plugin-clipboard-manager'
77
import { useGitRepository } from './hooks/useGitRepository'
88
import { useFileTree } from './hooks/useFileTree'
99
import { SelectedFilesPanel } from './components/SelectedFilesPanel'
@@ -21,6 +21,12 @@ import { TokenCountsProvider } from './context/TokenCountsContext'
2121
import { mapWithConcurrency } from './utils/concurrency'
2222
import { logError } from './utils/logger'
2323
import { debounce } from './utils/debounce'
24+
import {
25+
INVALID_CLIPBOARD_FORMAT_MESSAGE,
26+
NO_MATCHING_FILES_MESSAGE,
27+
parseClipboardPathLines,
28+
resolveSelectablePaths,
29+
} from './utils/clipboardBatchSelect'
2430

2531
function AppContent() {
2632
const [, setAppStatus] = useState<AppStatus>({ state: 'IDLE' })
@@ -152,6 +158,7 @@ function AppContent() {
152158
collapseAll,
153159
selectAll,
154160
deselectAll,
161+
addSelectedPaths,
155162
revealPath,
156163
} = useFileTree(setAppStatus)
157164

@@ -356,6 +363,37 @@ function AppContent() {
356363
}
357364
}, [gitClient, baseBranch, compareBranch, selectedPaths, diffContextLines, statusByPath, userInstructions, fileTree, includeFileTree, showChangedOnly, currentDir])
358365

366+
const handleBatchSelectFromClipboard = useCallback(async () => {
367+
if (!currentDir || !fileTree) return
368+
369+
try {
370+
const clipboardText = await readText()
371+
const lines = parseClipboardPathLines(clipboardText)
372+
if (lines.length === 0) {
373+
setErrorMessage(INVALID_CLIPBOARD_FORMAT_MESSAGE)
374+
return
375+
}
376+
377+
const selectableSet = new Set(statusByPath.keys())
378+
const { matched, invalidCount, outsideRepoCount } = resolveSelectablePaths(lines, currentDir, selectableSet)
379+
380+
if (matched.length === 0) {
381+
if (invalidCount === lines.length && outsideRepoCount === 0) {
382+
setErrorMessage(INVALID_CLIPBOARD_FORMAT_MESSAGE)
383+
} else {
384+
setErrorMessage(NO_MATCHING_FILES_MESSAGE)
385+
}
386+
return
387+
}
388+
389+
addSelectedPaths(matched)
390+
setErrorMessage(null)
391+
} catch (err) {
392+
logError('batchSelectFromClipboard', err)
393+
setErrorMessage('Failed to read clipboard content.')
394+
}
395+
}, [currentDir, fileTree, statusByPath, addSelectedPaths])
396+
359397
// Calculate file tree tokens
360398
useEffect(() => {
361399
if (!includeFileTree || !fileTree || selectedPaths.size === 0) {
@@ -539,6 +577,7 @@ function AppContent() {
539577
<button onClick={collapseAll} className="btn btn-ghost btn-icon" title="Collapse All" disabled={!fileTree}><ChevronsUp size={14} /></button>
540578
<button onClick={() => selectAll(treeFilter)} className="btn btn-ghost btn-icon" title="Select All" disabled={!fileTree}><CheckSquare size={14} /></button>
541579
<button onClick={() => deselectAll(treeFilter)} className="btn btn-ghost btn-icon" title="Deselect All" disabled={!fileTree}><Square size={14} /></button>
580+
<button onClick={() => void handleBatchSelectFromClipboard()} className="btn btn-ghost btn-icon" title="Batch Select from Clipboard" disabled={!fileTree || !currentDir}><ListChecks size={14} /></button>
542581
</div>
543582
<label className="tree-filter-checkbox">
544583
<input type="checkbox" checked={showChangedOnly} onChange={(e) => setShowChangedOnly(e.target.checked)} />

apps/desktop/src/hooks/useFileTree.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,15 @@ export function useFileTree(setAppStatus?: (s: AppStatus) => void) {
281281
})
282282
}, [fileTree, showChangedOnly])
283283

284+
const addSelectedPaths = useCallback((paths: string[]) => {
285+
if (!paths.length) return
286+
setSelectedPaths((prev) => {
287+
const next = new Set(prev)
288+
for (const path of paths) next.add(path)
289+
return next
290+
})
291+
}, [])
292+
284293
const revealPath = useCallback((path: string) => {
285294
if (!fileTree) return
286295

@@ -339,6 +348,7 @@ export function useFileTree(setAppStatus?: (s: AppStatus) => void) {
339348
collapseAll,
340349
selectAll,
341350
deselectAll,
351+
addSelectedPaths,
342352
revealPath,
343353
}
344354
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { describe, it, expect } from 'vitest'
2+
import {
3+
normalizeClipboardPath,
4+
parseClipboardPathLines,
5+
resolveSelectablePaths,
6+
} from './clipboardBatchSelect'
7+
8+
describe('clipboardBatchSelect', () => {
9+
it('parses newline-delimited paths and ignores blank lines', () => {
10+
const parsed = parseClipboardPathLines('\n src/App.tsx \n\nREADME.md\r\n')
11+
expect(parsed).toEqual(['src/App.tsx', 'README.md'])
12+
})
13+
14+
it('normalizes absolute path inside repo to relative', () => {
15+
const result = normalizeClipboardPath('/Users/me/repo/src/App.tsx', '/Users/me/repo')
16+
expect(result).toBe('src/App.tsx')
17+
})
18+
19+
it('normalizes windows separators for relative paths', () => {
20+
const result = normalizeClipboardPath('src\\components\\Tree.tsx', '/Users/me/repo')
21+
expect(result).toBe('src/components/Tree.tsx')
22+
})
23+
24+
it('returns null for absolute path outside repo', () => {
25+
const result = normalizeClipboardPath('/Users/me/other-repo/src/App.tsx', '/Users/me/repo')
26+
expect(result).toBeNull()
27+
})
28+
29+
it('resolves mixed input with inside and outside repo paths', () => {
30+
const selectableSet = new Set(['src/App.tsx', 'README.md'])
31+
const result = resolveSelectablePaths(
32+
['src/App.tsx', '/Users/me/repo/README.md', '/Users/me/other/repo.ts'],
33+
'/Users/me/repo',
34+
selectableSet
35+
)
36+
37+
expect(result.matched.sort()).toEqual(['README.md', 'src/App.tsx'])
38+
expect(result.invalidCount).toBe(0)
39+
expect(result.outsideRepoCount).toBe(1)
40+
})
41+
})
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
export const INVALID_CLIPBOARD_FORMAT_MESSAGE =
2+
'Invalid clipboard format. Paste one file path per line (relative or absolute path inside this repo). Example:\nsrc/App.tsx\napps/desktop/src/App.tsx'
3+
4+
export const NO_MATCHING_FILES_MESSAGE =
5+
'No matching files were found in this repository. Paste one file path per line (relative or absolute inside the opened repo).'
6+
7+
const DRIVE_LETTER_ABS = /^[a-zA-Z]:\//
8+
const WINDOWS_UNC_ABS = /^\/\//
9+
10+
function normalizeSlashes(value: string): string {
11+
return value.replace(/\\/g, '/')
12+
}
13+
14+
function stripTrailingSlashes(value: string): string {
15+
return value.replace(/\/+$/, '')
16+
}
17+
18+
function stripLeadingSlashes(value: string): string {
19+
return value.replace(/^\/+/, '')
20+
}
21+
22+
function stripLeadingDotSlash(value: string): string {
23+
return value.replace(/^\.\/+/, '')
24+
}
25+
26+
function isAbsolutePath(value: string): boolean {
27+
return value.startsWith('/') || DRIVE_LETTER_ABS.test(value) || WINDOWS_UNC_ABS.test(value)
28+
}
29+
30+
function equalForFs(a: string, b: string): boolean {
31+
const driveA = DRIVE_LETTER_ABS.test(a)
32+
const driveB = DRIVE_LETTER_ABS.test(b)
33+
if (driveA || driveB) return a.toLowerCase() === b.toLowerCase()
34+
return a === b
35+
}
36+
37+
function startsWithForFs(path: string, prefix: string): boolean {
38+
const drivePath = DRIVE_LETTER_ABS.test(path)
39+
const drivePrefix = DRIVE_LETTER_ABS.test(prefix)
40+
if (drivePath || drivePrefix) return path.toLowerCase().startsWith(prefix.toLowerCase())
41+
return path.startsWith(prefix)
42+
}
43+
44+
export function parseClipboardPathLines(text: string): string[] {
45+
return text
46+
.split(/\r?\n/)
47+
.map((line) => line.trim())
48+
.filter((line) => line.length > 0)
49+
}
50+
51+
export function normalizeClipboardPath(line: string, repoRoot: string): string | null {
52+
const normalizedRepoRoot = stripTrailingSlashes(normalizeSlashes(repoRoot.trim()))
53+
if (!normalizedRepoRoot) return null
54+
55+
const normalizedLine = normalizeSlashes(line.trim())
56+
if (!normalizedLine) return null
57+
58+
if (isAbsolutePath(normalizedLine)) {
59+
const repoPrefix = `${normalizedRepoRoot}/`
60+
if (equalForFs(normalizedLine, normalizedRepoRoot)) return null
61+
if (!startsWithForFs(normalizedLine, repoPrefix)) return null
62+
return stripLeadingSlashes(normalizedLine.slice(repoPrefix.length))
63+
}
64+
65+
const relative = stripLeadingDotSlash(stripLeadingSlashes(normalizedLine))
66+
return relative || null
67+
}
68+
69+
export function resolveSelectablePaths(
70+
lines: string[],
71+
repoRoot: string,
72+
selectableSet: Set<string>
73+
): { matched: string[]; invalidCount: number; outsideRepoCount: number } {
74+
const matched = new Set<string>()
75+
let invalidCount = 0
76+
let outsideRepoCount = 0
77+
78+
for (const line of lines) {
79+
const normalized = normalizeClipboardPath(line, repoRoot)
80+
if (!normalized) {
81+
if (isAbsolutePath(normalizeSlashes(line.trim()))) outsideRepoCount++
82+
else invalidCount++
83+
continue
84+
}
85+
const repoRelativePath = normalizeSlashes(normalized)
86+
if (selectableSet.has(repoRelativePath)) {
87+
matched.add(repoRelativePath)
88+
} else {
89+
outsideRepoCount++
90+
}
91+
}
92+
93+
return {
94+
matched: Array.from(matched),
95+
invalidCount,
96+
outsideRepoCount,
97+
}
98+
}

0 commit comments

Comments
 (0)