From 0e3f4824fd3e73dac4719eedc860f9c80d6e2c34 Mon Sep 17 00:00:00 2001
From: kccarlos
Date: Tue, 10 Feb 2026 17:16:56 -0800
Subject: [PATCH 01/55] feat(desktop): add batch file selection from clipboard
in file tree
---
apps/desktop/src-tauri/tauri.conf.json | 1 +
apps/desktop/src/App.tsx | 41 +++++++-
apps/desktop/src/hooks/useFileTree.ts | 10 ++
.../src/utils/clipboardBatchSelect.test.ts | 41 ++++++++
.../desktop/src/utils/clipboardBatchSelect.ts | 98 +++++++++++++++++++
5 files changed, 190 insertions(+), 1 deletion(-)
create mode 100644 apps/desktop/src/utils/clipboardBatchSelect.test.ts
create mode 100644 apps/desktop/src/utils/clipboardBatchSelect.ts
diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json
index 75ef9d2..209ad62 100644
--- a/apps/desktop/src-tauri/tauri.conf.json
+++ b/apps/desktop/src-tauri/tauri.conf.json
@@ -49,6 +49,7 @@
"dialog:allow-open",
"fs:allow-read-dir",
"fs:allow-read-file",
+ "clipboard-manager:allow-read-text",
"clipboard-manager:allow-write-text"
]
}
diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx
index 4cd80ea..6282cb9 100644
--- a/apps/desktop/src/App.tsx
+++ b/apps/desktop/src/App.tsx
@@ -3,7 +3,7 @@ import './App.css'
import { ChevronsDown, ChevronsUp, CheckSquare, Square, Sun, Moon, Folder, FolderGit2, ListChecks, Copy, ArrowLeftRight } from 'lucide-react'
import { FileTreeView, PreviewModal, GitHubStarIconButton, BugIconButton } from '@gitcontext/ui'
import { type FileDiffStatus, isBinaryPath, MAX_CONCURRENT_READS } from '@gitcontext/core'
-import { writeText } from '@tauri-apps/plugin-clipboard-manager'
+import { readText, writeText } from '@tauri-apps/plugin-clipboard-manager'
import { useGitRepository } from './hooks/useGitRepository'
import { useFileTree } from './hooks/useFileTree'
import { SelectedFilesPanel } from './components/SelectedFilesPanel'
@@ -21,6 +21,12 @@ import { TokenCountsProvider } from './context/TokenCountsContext'
import { mapWithConcurrency } from './utils/concurrency'
import { logError } from './utils/logger'
import { debounce } from './utils/debounce'
+import {
+ INVALID_CLIPBOARD_FORMAT_MESSAGE,
+ NO_MATCHING_FILES_MESSAGE,
+ parseClipboardPathLines,
+ resolveSelectablePaths,
+} from './utils/clipboardBatchSelect'
function AppContent() {
const [, setAppStatus] = useState({ state: 'IDLE' })
@@ -152,6 +158,7 @@ function AppContent() {
collapseAll,
selectAll,
deselectAll,
+ addSelectedPaths,
revealPath,
} = useFileTree(setAppStatus)
@@ -356,6 +363,37 @@ function AppContent() {
}
}, [gitClient, baseBranch, compareBranch, selectedPaths, diffContextLines, statusByPath, userInstructions, fileTree, includeFileTree, showChangedOnly, currentDir])
+ const handleBatchSelectFromClipboard = useCallback(async () => {
+ if (!currentDir || !fileTree) return
+
+ try {
+ const clipboardText = await readText()
+ const lines = parseClipboardPathLines(clipboardText)
+ if (lines.length === 0) {
+ setErrorMessage(INVALID_CLIPBOARD_FORMAT_MESSAGE)
+ return
+ }
+
+ const selectableSet = new Set(statusByPath.keys())
+ const { matched, invalidCount, outsideRepoCount } = resolveSelectablePaths(lines, currentDir, selectableSet)
+
+ if (matched.length === 0) {
+ if (invalidCount === lines.length && outsideRepoCount === 0) {
+ setErrorMessage(INVALID_CLIPBOARD_FORMAT_MESSAGE)
+ } else {
+ setErrorMessage(NO_MATCHING_FILES_MESSAGE)
+ }
+ return
+ }
+
+ addSelectedPaths(matched)
+ setErrorMessage(null)
+ } catch (err) {
+ logError('batchSelectFromClipboard', err)
+ setErrorMessage('Failed to read clipboard content.')
+ }
+ }, [currentDir, fileTree, statusByPath, addSelectedPaths])
+
// Calculate file tree tokens
useEffect(() => {
if (!includeFileTree || !fileTree || selectedPaths.size === 0) {
@@ -539,6 +577,7 @@ function AppContent() {
+
- void selectNewRepo()} disabled={repoStatus.state === 'loading'}>
+ void handleSelectNewRepo()} disabled={repoStatus.state === 'loading'}>
{repoStatus.state === 'loading' ? 'Opening...' : 'Select Project Folder'}
@@ -564,10 +1033,14 @@ function AppContent() {
onBaseBranchChange={setBaseBranch}
onCompareBranchChange={setCompareBranch}
onFlip={flipBranches}
- onRefresh={refreshRepo}
+ onRefresh={handleRefreshWorkspace}
disabled={isLoading}
- projectName={currentDir ? currentDir.split('/').pop() || currentDir : undefined}
- projectPath={currentDir || undefined}
+ workspaces={workspaceItems}
+ selectedWorkspaceId={selectedWorkspaceId}
+ currentWorkspacePath={currentDir || ''}
+ onWorkspaceSelect={handleSelectWorkspace}
+ onSaveWorkspace={handleSaveWorkspace}
+ onDeleteWorkspace={handleDeleteWorkspace}
/>
diff --git a/apps/desktop/src/components/DiffControlBar.test.tsx b/apps/desktop/src/components/DiffControlBar.test.tsx
index b93901c..9e2bc50 100644
--- a/apps/desktop/src/components/DiffControlBar.test.tsx
+++ b/apps/desktop/src/components/DiffControlBar.test.tsx
@@ -8,6 +8,15 @@ describe('DiffControlBar', () => {
branches: ['main', 'dev', 'feature/test'],
baseBranch: 'main',
compareBranch: 'dev',
+ workspaces: [
+ { id: 'ws-1', name: 'Main Repo', path: '/tmp/main', folderName: 'main', updatedAt: '2026-01-01T00:00:00.000Z' },
+ { id: 'ws-2', name: 'Docs', path: '/tmp/docs', folderName: 'docs', updatedAt: '2026-01-02T00:00:00.000Z' },
+ ],
+ selectedWorkspaceId: '',
+ currentWorkspacePath: '/tmp/main',
+ onWorkspaceSelect: vi.fn(),
+ onSaveWorkspace: vi.fn(),
+ onDeleteWorkspace: vi.fn(),
onBaseBranchChange: vi.fn(),
onCompareBranchChange: vi.fn(),
onFlip: vi.fn(),
@@ -34,12 +43,59 @@ describe('DiffControlBar', () => {
it('renders all branch options in both selectors', () => {
render(
)
- const selects = screen.getAllByRole('combobox')
- selects.forEach((select) => {
- const options = Array.from(select.querySelectorAll('option'))
- expect(options).toHaveLength(3)
- expect(options.map(o => o.value)).toEqual(['main', 'dev', 'feature/test'])
- })
+ const baseSelect = screen.getByLabelText(/base/i)
+ const compareSelect = screen.getByLabelText(/compare/i)
+ const baseOptions = Array.from(baseSelect.querySelectorAll('option'))
+ const compareOptions = Array.from(compareSelect.querySelectorAll('option'))
+
+ expect(baseOptions).toHaveLength(3)
+ expect(compareOptions).toHaveLength(3)
+ expect(baseOptions.map(o => o.value)).toEqual(['main', 'dev', 'feature/test'])
+ expect(compareOptions.map(o => o.value)).toEqual(['main', 'dev', 'feature/test'])
+ })
+
+ it('renders workspace selector and controls', () => {
+ render(
)
+
+ expect(screen.getByLabelText(/workspace/i)).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /save current workspace/i })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /delete selected workspace/i })).toBeInTheDocument()
+ })
+
+ it('calls onWorkspaceSelect when a saved workspace is selected', async () => {
+ const user = userEvent.setup()
+ const onWorkspaceSelect = vi.fn()
+
+ render(
)
+
+ const workspaceSelect = screen.getByLabelText(/workspace/i)
+ await user.selectOptions(workspaceSelect, 'ws-2')
+
+ expect(onWorkspaceSelect).toHaveBeenCalledWith('ws-2')
+ })
+
+ it('calls onSaveWorkspace when save button is clicked', async () => {
+ const user = userEvent.setup()
+ const onSaveWorkspace = vi.fn()
+
+ render(
)
+ await user.click(screen.getByRole('button', { name: /save current workspace/i }))
+ expect(onSaveWorkspace).toHaveBeenCalledOnce()
+ })
+
+ it('calls onDeleteWorkspace when delete button is clicked', async () => {
+ const user = userEvent.setup()
+ const onDeleteWorkspace = vi.fn()
+
+ render(
+
,
+ )
+ await user.click(screen.getByRole('button', { name: /delete selected workspace/i }))
+ expect(onDeleteWorkspace).toHaveBeenCalledOnce()
})
it('displays "My Working Directory" for __WORKDIR__ branch', () => {
@@ -122,13 +178,19 @@ describe('DiffControlBar', () => {
it('disables all controls when disabled prop is true', () => {
render(
)
+ const workspaceSelect = screen.getByLabelText(/workspace/i)
const baseSelect = screen.getByLabelText(/base/i)
const compareSelect = screen.getByLabelText(/compare/i)
+ const saveButton = screen.getByRole('button', { name: /save current workspace/i })
+ const deleteButton = screen.getByRole('button', { name: /delete selected workspace/i })
const flipButton = screen.getByRole('button', { name: /swap/i })
const refreshButton = screen.getByRole('button', { name: /refresh/i })
+ expect(workspaceSelect).toBeDisabled()
expect(baseSelect).toBeDisabled()
expect(compareSelect).toBeDisabled()
+ expect(saveButton).toBeDisabled()
+ expect(deleteButton).toBeDisabled()
expect(flipButton).toBeDisabled()
expect(refreshButton).toBeDisabled()
})
@@ -158,6 +220,7 @@ describe('DiffControlBar', () => {
const { container } = render(
)
expect(container.querySelector('.gc-diff-bar')).toBeInTheDocument()
+ expect(container.querySelector('.diff-bar-workspace-controls')).toBeInTheDocument()
expect(container.querySelector('.diff-bar-branch-selector')).toBeInTheDocument()
// Arrow removed - swap button is sufficient visual indicator
})
diff --git a/apps/desktop/src/components/DiffControlBar.tsx b/apps/desktop/src/components/DiffControlBar.tsx
index cbd8852..0a30573 100644
--- a/apps/desktop/src/components/DiffControlBar.tsx
+++ b/apps/desktop/src/components/DiffControlBar.tsx
@@ -1,4 +1,5 @@
-import { ArrowLeftRight, RefreshCw, Folder } from 'lucide-react'
+import { ArrowLeftRight, RefreshCw, Save, Trash2 } from 'lucide-react'
+import type { WorkspaceListItem } from '../utils/workspaceStore'
type DiffControlBarProps = {
branches: string[]
@@ -9,8 +10,12 @@ type DiffControlBarProps = {
onFlip: () => void
onRefresh: () => void
disabled?: boolean
- projectName?: string
- projectPath?: string
+ workspaces: WorkspaceListItem[]
+ selectedWorkspaceId: string | ''
+ currentWorkspacePath: string
+ onWorkspaceSelect: (workspaceId: string | '') => void
+ onSaveWorkspace: () => void
+ onDeleteWorkspace: () => void
}
export function DiffControlBar({
@@ -22,22 +27,68 @@ export function DiffControlBar({
onFlip,
onRefresh,
disabled = false,
- projectName,
- projectPath,
+ workspaces,
+ selectedWorkspaceId,
+ currentWorkspacePath,
+ onWorkspaceSelect,
+ onSaveWorkspace,
+ onDeleteWorkspace,
}: DiffControlBarProps) {
const formatBranchLabel = (branch: string) =>
branch === '__WORKDIR__' ? 'My Working Directory' : branch
+ const selectedWorkspace =
+ selectedWorkspaceId !== ''
+ ? workspaces.find((workspace) => workspace.id === selectedWorkspaceId) ?? null
+ : null
+ const fallbackFolder = currentWorkspacePath
+ ? currentWorkspacePath.split('/').filter(Boolean).pop() || currentWorkspacePath
+ : ''
+ const unsavedLabel = currentWorkspacePath ? `Unsaved: ${fallbackFolder}` : 'Unsaved Workspace'
+ const workspaceTitle = selectedWorkspace?.path || currentWorkspacePath || 'Workspace'
return (
- {projectName && (
-
-
-
- {projectName}
-
+
+
+ Workspace
+
- )}
+
+
+
+
+
+
+
Base