mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
feat(file-explorer): add gitignored visibility toggle (#2022)
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { checkIgnoredPaths } from './check-ignored-paths'
|
||||
import { gitExecFileAsync } from './runner'
|
||||
|
||||
vi.mock('./runner', () => ({
|
||||
gitExecFileAsync: vi.fn()
|
||||
}))
|
||||
|
||||
const gitExecFileAsyncMock = vi.mocked(gitExecFileAsync)
|
||||
|
||||
describe('checkIgnoredPaths', () => {
|
||||
beforeEach(() => {
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
})
|
||||
|
||||
it('returns ignored paths from git check-ignore output', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'dist/bundle.js\n.env\n', stderr: '' })
|
||||
|
||||
await expect(
|
||||
checkIgnoredPaths('/repo', ['dist/bundle.js', 'src/index.ts', '.env'])
|
||||
).resolves.toEqual(['dist/bundle.js', '.env'])
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
[
|
||||
'-c',
|
||||
'core.quotePath=false',
|
||||
'check-ignore',
|
||||
'--',
|
||||
'dist/bundle.js',
|
||||
'src/index.ts',
|
||||
'.env'
|
||||
],
|
||||
{ cwd: '/repo' }
|
||||
)
|
||||
})
|
||||
|
||||
it('treats exit code 1 as no ignored paths', async () => {
|
||||
gitExecFileAsyncMock.mockRejectedValue(Object.assign(new Error('no matches'), { code: 1 }))
|
||||
|
||||
await expect(checkIgnoredPaths('/repo', ['src/index.ts'])).resolves.toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { gitExecFileAsync } from './runner'
|
||||
|
||||
const CHECK_IGNORE_CHUNK_SIZE = 100
|
||||
|
||||
type GitExecError = Error & { stdout?: string; code?: number | string }
|
||||
|
||||
function parseCheckIgnoreOutput(stdout: string): string[] {
|
||||
return stdout.split(/\r?\n/).filter(Boolean)
|
||||
}
|
||||
|
||||
async function runCheckIgnoreChunk(
|
||||
worktreePath: string,
|
||||
relativePaths: string[]
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const { stdout } = await gitExecFileAsync(
|
||||
['-c', 'core.quotePath=false', 'check-ignore', '--', ...relativePaths],
|
||||
{ cwd: worktreePath }
|
||||
)
|
||||
return parseCheckIgnoreOutput(stdout)
|
||||
} catch (error) {
|
||||
const gitError = error as GitExecError
|
||||
if (gitError.code === 1) {
|
||||
return parseCheckIgnoreOutput(gitError.stdout ?? '')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkIgnoredPaths(
|
||||
worktreePath: string,
|
||||
relativePaths: string[]
|
||||
): Promise<string[]> {
|
||||
const ignored = new Set<string>()
|
||||
for (let i = 0; i < relativePaths.length; i += CHECK_IGNORE_CHUNK_SIZE) {
|
||||
const chunk = relativePaths.slice(i, i + CHECK_IGNORE_CHUNK_SIZE)
|
||||
for (const ignoredPath of await runCheckIgnoreChunk(worktreePath, chunk)) {
|
||||
ignored.add(ignoredPath)
|
||||
}
|
||||
}
|
||||
return Array.from(ignored)
|
||||
}
|
||||
@@ -358,6 +358,30 @@ describe('getStatus', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('omits ignored files by default and parses them when requested', async () => {
|
||||
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: '! dist/\n! generated/file.js\n'
|
||||
})
|
||||
|
||||
const result = await getStatus('/repo', { includeIgnored: true })
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
[
|
||||
'-c',
|
||||
'core.quotePath=false',
|
||||
'status',
|
||||
'--porcelain=v2',
|
||||
'--branch',
|
||||
'--untracked-files=all',
|
||||
'--ignored=matching'
|
||||
],
|
||||
{ cwd: '/repo' }
|
||||
)
|
||||
expect(result.ignoredPaths).toEqual(['dist/', 'generated/file.js'])
|
||||
})
|
||||
|
||||
it('parses branch identity from porcelain v2 branch headers', async () => {
|
||||
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
|
||||
@@ -25,6 +25,7 @@ const {
|
||||
bulkUnstageFilesMock,
|
||||
bulkDiscardChangesMock,
|
||||
discardChangesMock,
|
||||
checkIgnoredPathsMock,
|
||||
listWorktreesMock,
|
||||
resolveCommitMessageSettingsMock,
|
||||
generateCommitMessageFromContextMock,
|
||||
@@ -53,6 +54,7 @@ const {
|
||||
bulkUnstageFilesMock: vi.fn(),
|
||||
bulkDiscardChangesMock: vi.fn(),
|
||||
discardChangesMock: vi.fn(),
|
||||
checkIgnoredPathsMock: vi.fn(),
|
||||
listWorktreesMock: vi.fn(),
|
||||
resolveCommitMessageSettingsMock: vi.fn(),
|
||||
generateCommitMessageFromContextMock: vi.fn(),
|
||||
@@ -95,6 +97,10 @@ vi.mock('../git/status', () => ({
|
||||
discardChanges: discardChangesMock
|
||||
}))
|
||||
|
||||
vi.mock('../git/check-ignored-paths', () => ({
|
||||
checkIgnoredPaths: checkIgnoredPathsMock
|
||||
}))
|
||||
|
||||
vi.mock('../git/worktree', () => ({
|
||||
listWorktrees: listWorktreesMock
|
||||
}))
|
||||
@@ -497,6 +503,39 @@ describe('registerFilesystemHandlers', () => {
|
||||
expect(sshProvider.getStatus).toHaveBeenCalledWith('/remote/repo', { includeIgnored: true })
|
||||
})
|
||||
|
||||
it('checks ignored paths through local and SSH git providers', async () => {
|
||||
registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH])
|
||||
checkIgnoredPathsMock.mockResolvedValue(['dist/bundle.js'])
|
||||
const sshProvider = {
|
||||
checkIgnoredPaths: vi.fn().mockResolvedValue(['build/output.js'])
|
||||
}
|
||||
getSshGitProviderMock.mockReturnValue(sshProvider)
|
||||
|
||||
registerFilesystemHandlers(store as never)
|
||||
|
||||
await expect(
|
||||
handlers.get('git:checkIgnored')!(null, {
|
||||
worktreePath: WORKTREE_FEATURE_PATH,
|
||||
paths: ['dist/bundle.js', 'src/index.ts']
|
||||
})
|
||||
).resolves.toEqual(['dist/bundle.js'])
|
||||
await expect(
|
||||
handlers.get('git:checkIgnored')!(null, {
|
||||
worktreePath: '/remote/repo',
|
||||
connectionId: 'ssh-1',
|
||||
paths: ['build/output.js']
|
||||
})
|
||||
).resolves.toEqual(['build/output.js'])
|
||||
|
||||
expect(checkIgnoredPathsMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, [
|
||||
path.join('dist', 'bundle.js'),
|
||||
path.join('src', 'index.ts')
|
||||
])
|
||||
expect(sshProvider.checkIgnoredPaths).toHaveBeenCalledWith('/remote/repo', [
|
||||
path.join('build', 'output.js')
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects git file paths that escape the selected worktree', async () => {
|
||||
registerFilesystemHandlers(store as never)
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
import { getPullRequestDraftContext } from '../text-generation/pull-request-context'
|
||||
import { getUpstreamStatus } from '../git/upstream'
|
||||
import { gitFetch, gitPull, gitPush } from '../git/remote'
|
||||
import { checkIgnoredPaths } from '../git/check-ignored-paths'
|
||||
import { assertGitPushTargetShape } from '../../shared/git-push-target-validation'
|
||||
import { validateGitPushTarget } from '../git/push-target-validation'
|
||||
import { getRemoteFileUrl } from '../git/repo'
|
||||
@@ -516,6 +517,26 @@ export function registerFilesystemHandlers(
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'git:checkIgnored',
|
||||
async (
|
||||
_event,
|
||||
args: { worktreePath: string; paths: string[]; connectionId?: string }
|
||||
): Promise<string[]> => {
|
||||
if (args.connectionId) {
|
||||
const paths = args.paths.map((p) => validateGitRelativeFilePath(args.worktreePath, p))
|
||||
const provider = getSshGitProvider(args.connectionId)
|
||||
if (!provider) {
|
||||
throw new Error(`No git provider for connection "${args.connectionId}"`)
|
||||
}
|
||||
return provider.checkIgnoredPaths(args.worktreePath, paths)
|
||||
}
|
||||
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
||||
const paths = args.paths.map((p) => validateGitRelativeFilePath(worktreePath, p))
|
||||
return checkIgnoredPaths(worktreePath, paths)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'git:history',
|
||||
async (
|
||||
|
||||
@@ -595,6 +595,7 @@ describe('Store', () => {
|
||||
expect(store.getSettings().editorAutoSaveDelayMs).toBe(1000)
|
||||
expect(store.getSettings().refreshLocalBaseRefOnWorktreeCreate).toBe(false)
|
||||
expect(store.getSettings().rightSidebarOpenByDefault).toBe(true)
|
||||
expect(store.getSettings().showGitIgnoredFiles).toBe(true)
|
||||
expect(store.getSettings().showTasksButton).toBe(true)
|
||||
expect(store.getSettings().combinedDiffFileTreeVisibleByDefault).toBe(false)
|
||||
expect(store.getSettings().visibleTaskProviders).toEqual(['github', 'gitlab', 'linear'])
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable max-lines -- Why: this suite covers the SSH git provider's one-RPC-per-method contract; splitting it would duplicate the shared mux fixture. */
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { SshGitProvider } from './ssh-git-provider'
|
||||
|
||||
@@ -57,6 +58,18 @@ describe('SshGitProvider', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('checkIgnoredPaths sends git.checkIgnored request', async () => {
|
||||
mux.request.mockResolvedValue(['dist/bundle.js'])
|
||||
|
||||
const result = await provider.checkIgnoredPaths('/home/user/repo', ['dist/bundle.js'])
|
||||
|
||||
expect(mux.request).toHaveBeenCalledWith('git.checkIgnored', {
|
||||
worktreePath: '/home/user/repo',
|
||||
paths: ['dist/bundle.js']
|
||||
})
|
||||
expect(result).toEqual(['dist/bundle.js'])
|
||||
})
|
||||
|
||||
it('getHistory sends git.history request', async () => {
|
||||
const historyResult = {
|
||||
items: [],
|
||||
|
||||
@@ -44,6 +44,13 @@ export class SshGitProvider implements IGitProvider {
|
||||
})) as GitStatusResult
|
||||
}
|
||||
|
||||
async checkIgnoredPaths(worktreePath: string, relativePaths: string[]): Promise<string[]> {
|
||||
return (await this.mux.request('git.checkIgnored', {
|
||||
worktreePath,
|
||||
paths: relativePaths
|
||||
})) as string[]
|
||||
}
|
||||
|
||||
async getHistory(
|
||||
worktreePath: string,
|
||||
options: GitHistoryOptions = {}
|
||||
|
||||
@@ -145,6 +145,7 @@ export type IFilesystemProvider = {
|
||||
|
||||
export type IGitProvider = {
|
||||
getStatus(worktreePath: string, options?: { includeIgnored?: boolean }): Promise<GitStatusResult>
|
||||
checkIgnoredPaths(worktreePath: string, relativePaths: string[]): Promise<string[]>
|
||||
getHistory(worktreePath: string, options?: GitHistoryOptions): Promise<GitHistoryResult>
|
||||
commit(worktreePath: string, message: string): Promise<{ success: boolean; error?: string }>
|
||||
getStagedCommitContext(worktreePath: string): Promise<CommitMessageDraftContext | null>
|
||||
|
||||
@@ -35,6 +35,7 @@ import { getHistory as getGitHistory } from '../git/history'
|
||||
import { getUpstreamStatus } from '../git/upstream'
|
||||
import { gitFetch, gitPull, gitPush } from '../git/remote'
|
||||
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import { checkIgnoredPaths } from '../git/check-ignored-paths'
|
||||
import {
|
||||
cancelGenerateCommitMessageLocal,
|
||||
cancelGeneratePullRequestFieldsLocal,
|
||||
@@ -95,6 +96,21 @@ export class RuntimeGitCommands {
|
||||
: getGitStatus(target.worktree.path)
|
||||
}
|
||||
|
||||
async checkRuntimeGitIgnoredPaths(
|
||||
worktreeSelector: string,
|
||||
relativePaths: string[]
|
||||
): Promise<string[]> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
|
||||
if (target.connectionId) {
|
||||
if (!provider) {
|
||||
throw new Error('remote_git_unavailable')
|
||||
}
|
||||
return provider.checkIgnoredPaths(target.worktree.path, relativePaths)
|
||||
}
|
||||
return checkIgnoredPaths(target.worktree.path, relativePaths)
|
||||
}
|
||||
|
||||
async getRuntimeGitHistory(
|
||||
worktreeSelector: string,
|
||||
options: GitHistoryOptions = {}
|
||||
|
||||
@@ -1225,6 +1225,8 @@ export class OrcaRuntimeService {
|
||||
|
||||
getRuntimeGitStatus: RuntimeGitCommands['getRuntimeGitStatus'] =
|
||||
this.gitCommands.getRuntimeGitStatus.bind(this.gitCommands)
|
||||
checkRuntimeGitIgnoredPaths: RuntimeGitCommands['checkRuntimeGitIgnoredPaths'] =
|
||||
this.gitCommands.checkRuntimeGitIgnoredPaths.bind(this.gitCommands)
|
||||
getRuntimeGitHistory: RuntimeGitCommands['getRuntimeGitHistory'] =
|
||||
this.gitCommands.getRuntimeGitHistory.bind(this.gitCommands)
|
||||
getRuntimeGitConflictOperation: RuntimeGitCommands['getRuntimeGitConflictOperation'] =
|
||||
|
||||
@@ -11,6 +11,10 @@ export const GitStatusParams = WorktreeSelector.extend({
|
||||
includeIgnored: z.boolean().optional()
|
||||
})
|
||||
|
||||
export const GitCheckIgnored = WorktreeSelector.extend({
|
||||
paths: z.array(z.string().min(1, 'Missing path')).max(2000)
|
||||
})
|
||||
|
||||
export const GitFilePath = WorktreeSelector.extend({
|
||||
filePath: z
|
||||
.unknown()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable max-lines -- Why: git RPC methods share one dispatcher fixture, and keeping the contract cases together makes method coverage easy to audit. */
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import type { RpcRequest } from '../core'
|
||||
@@ -54,6 +55,30 @@ describe('git RPC methods', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('returns ignored paths for selected explorer rows', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
checkRuntimeGitIgnoredPaths: vi.fn().mockResolvedValue(['dist/bundle.js'])
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('git.checkIgnored', {
|
||||
worktree: 'id:wt-1',
|
||||
paths: ['dist/bundle.js', 'src/index.ts']
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.checkRuntimeGitIgnoredPaths).toHaveBeenCalledWith('id:wt-1', [
|
||||
'dist/bundle.js',
|
||||
'src/index.ts'
|
||||
])
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: ['dist/bundle.js']
|
||||
})
|
||||
})
|
||||
|
||||
it('returns a worktree file diff', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
GitBranchCompare,
|
||||
GitBranchDiff,
|
||||
GitBulkPaths,
|
||||
GitCheckIgnored,
|
||||
GitCommit,
|
||||
GitCommitCompare,
|
||||
GitCommitDiff,
|
||||
@@ -27,6 +28,12 @@ export const GIT_METHODS: RpcMethod[] = [
|
||||
? runtime.getRuntimeGitStatus(params.worktree)
|
||||
: runtime.getRuntimeGitStatus(params.worktree, { includeIgnored: params.includeIgnored })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'git.checkIgnored',
|
||||
params: GitCheckIgnored,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.checkRuntimeGitIgnoredPaths(params.worktree, params.paths)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'git.history',
|
||||
params: GitHistory,
|
||||
|
||||
@@ -1366,6 +1366,11 @@ export type PreloadApi = {
|
||||
connectionId?: string
|
||||
includeIgnored?: boolean
|
||||
}) => Promise<GitStatusResult>
|
||||
checkIgnored: (args: {
|
||||
worktreePath: string
|
||||
paths: string[]
|
||||
connectionId?: string
|
||||
}) => Promise<string[]>
|
||||
history: (
|
||||
args: { worktreePath: string; connectionId?: string } & GitHistoryOptions
|
||||
) => Promise<GitHistoryResult>
|
||||
|
||||
@@ -1917,6 +1917,11 @@ const api = {
|
||||
connectionId?: string
|
||||
includeIgnored?: boolean
|
||||
}): Promise<unknown> => ipcRenderer.invoke('git:status', args),
|
||||
checkIgnored: (args: {
|
||||
worktreePath: string
|
||||
paths: string[]
|
||||
connectionId?: string
|
||||
}): Promise<string[]> => ipcRenderer.invoke('git:checkIgnored', args),
|
||||
history: (
|
||||
args: { worktreePath: string; connectionId?: string } & GitHistoryOptions
|
||||
): Promise<GitHistoryResult> => ipcRenderer.invoke('git:history', args),
|
||||
|
||||
@@ -121,3 +121,34 @@ export async function getStatusOp(
|
||||
...(includeIgnored ? { ignoredPaths } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function parseCheckIgnoreOutput(stdout: string): string[] {
|
||||
return stdout.split(/\r?\n/).filter(Boolean)
|
||||
}
|
||||
|
||||
export async function checkIgnoredPathsOp(
|
||||
git: GitExec,
|
||||
params: Record<string, unknown>
|
||||
): Promise<string[]> {
|
||||
const worktreePath = params.worktreePath as string
|
||||
const paths = Array.isArray(params.paths)
|
||||
? params.paths.filter((path): path is string => typeof path === 'string' && path.length > 0)
|
||||
: []
|
||||
if (paths.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await git(
|
||||
['-c', 'core.quotePath=false', 'check-ignore', '--', ...paths],
|
||||
worktreePath
|
||||
)
|
||||
return parseCheckIgnoreOutput(stdout)
|
||||
} catch (error) {
|
||||
const gitError = error as Error & { code?: number | string; stdout?: string }
|
||||
if (gitError.code === 1) {
|
||||
return parseCheckIgnoreOutput(gitError.stdout ?? '')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ describe('GitHandler', () => {
|
||||
it('registers all expected handlers', () => {
|
||||
const methods = Array.from(dispatcher._requestHandlers.keys())
|
||||
expect(methods).toContain('git.status')
|
||||
expect(methods).toContain('git.checkIgnored')
|
||||
expect(methods).toContain('git.history')
|
||||
expect(methods).toContain('git.commit')
|
||||
expect(methods).toContain('git.diff')
|
||||
@@ -144,6 +145,23 @@ describe('GitHandler', () => {
|
||||
expect(ignoredResult.ignoredPaths).toEqual(expect.arrayContaining(['dist/', '.env']))
|
||||
})
|
||||
|
||||
it('checks ignored status for selected paths', async () => {
|
||||
gitInit(tmpDir)
|
||||
writeFileSync(path.join(tmpDir, '.gitignore'), 'dist/\n.env\n')
|
||||
gitCommit(tmpDir, 'initial')
|
||||
mkdirSync(path.join(tmpDir, 'dist'), { recursive: true })
|
||||
writeFileSync(path.join(tmpDir, 'dist', 'bundle.js'), 'compiled')
|
||||
writeFileSync(path.join(tmpDir, '.env'), 'TOKEN=secret')
|
||||
|
||||
const result = (await dispatcher.callRequest('git.checkIgnored', {
|
||||
worktreePath: tmpDir,
|
||||
paths: ['dist/bundle.js', 'src/index.ts', '.env']
|
||||
})) as string[]
|
||||
|
||||
expect(result).toEqual(expect.arrayContaining(['dist/bundle.js', '.env']))
|
||||
expect(result).not.toContain('src/index.ts')
|
||||
})
|
||||
|
||||
it('detects modified files', async () => {
|
||||
gitInit(tmpDir)
|
||||
writeFileSync(path.join(tmpDir, 'file.txt'), 'original')
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from './git-handler-ops'
|
||||
import { commitCompare as commitCompareOp, commitDiffEntry } from './git-handler-commit-diff-ops'
|
||||
import { commitChangesRelay, addWorktreeOp, removeWorktreeOp } from './git-handler-worktree-ops'
|
||||
import { detectConflictOperation, getStatusOp } from './git-handler-status-ops'
|
||||
import { checkIgnoredPathsOp, detectConflictOperation, getStatusOp } from './git-handler-status-ops'
|
||||
import { resolveRelayPushTarget } from './git-handler-push-target'
|
||||
import { normalizeGitErrorMessage, isNoUpstreamError } from '../shared/git-remote-error'
|
||||
import { loadGitHistoryFromExecutor } from '../shared/git-history'
|
||||
@@ -37,6 +37,7 @@ export class GitHandler {
|
||||
|
||||
private registerHandlers(): void {
|
||||
this.dispatcher.onRequest('git.status', (p) => this.getStatus(p))
|
||||
this.dispatcher.onRequest('git.checkIgnored', (p) => this.checkIgnored(p))
|
||||
this.dispatcher.onRequest('git.history', (p) => this.history(p))
|
||||
this.dispatcher.onRequest('git.commit', (p) => this.commit(p))
|
||||
this.dispatcher.onRequest('git.diff', (p) => this.getDiff(p))
|
||||
@@ -87,6 +88,10 @@ export class GitHandler {
|
||||
return getStatusOp(this.git.bind(this), params)
|
||||
}
|
||||
|
||||
private async checkIgnored(params: Record<string, unknown>) {
|
||||
return checkIgnoredPathsOp(this.git.bind(this), params)
|
||||
}
|
||||
|
||||
private async history(params: Record<string, unknown>) {
|
||||
const worktreePath = params.worktreePath as string
|
||||
return loadGitHistoryFromExecutor(this.git.bind(this), worktreePath, {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ListCollapse, Loader2, RefreshCw } from 'lucide-react'
|
||||
import { EyeOff, ListCollapse, Loader2, RefreshCw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FileExplorerToolbar } from './FileExplorerToolbar'
|
||||
import { FileExplorerRow, shouldShowCollapseFolderAction } from './FileExplorerRow'
|
||||
@@ -52,6 +52,29 @@ function findCollapseAllButton(node: unknown): ReactElementLike {
|
||||
return found
|
||||
}
|
||||
|
||||
function findGitIgnoredButton(node: unknown): ReactElementLike {
|
||||
let found: ReactElementLike | null = null
|
||||
visit(node, (entry) => {
|
||||
if (entry.type === Button && entry.props['aria-label'] === 'Hide Git Ignored Files') {
|
||||
found = entry
|
||||
}
|
||||
})
|
||||
if (!found) {
|
||||
throw new Error('git ignored button not found')
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
function queryGitIgnoredButton(node: unknown): ReactElementLike | null {
|
||||
let found: ReactElementLike | null = null
|
||||
visit(node, (entry) => {
|
||||
if (entry.type === Button && entry.props['aria-label'] === 'Hide Git Ignored Files') {
|
||||
found = entry
|
||||
}
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
function findFileExplorerRow(node: unknown): ReactElementLike {
|
||||
let found: ReactElementLike | null = null
|
||||
visit(node, (entry) => {
|
||||
@@ -103,15 +126,23 @@ function makeRefreshState(
|
||||
}
|
||||
}
|
||||
|
||||
function makeToolbar(overrides: Partial<Parameters<typeof FileExplorerToolbar>[0]> = {}) {
|
||||
return FileExplorerToolbar({
|
||||
repoName: 'orca',
|
||||
refresh: makeRefreshState(),
|
||||
canCollapseAll: false,
|
||||
onCollapseAll: vi.fn(),
|
||||
showGitIgnoredFilesToggle: true,
|
||||
showGitIgnoredFiles: true,
|
||||
onToggleGitIgnoredFiles: vi.fn(),
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
describe('FileExplorerToolbar', () => {
|
||||
it('fires the refresh action from the icon button', () => {
|
||||
const onRefresh = vi.fn()
|
||||
const element = FileExplorerToolbar({
|
||||
repoName: 'orca',
|
||||
refresh: makeRefreshState({ handleRefresh: onRefresh }),
|
||||
canCollapseAll: false,
|
||||
onCollapseAll: vi.fn()
|
||||
})
|
||||
const element = makeToolbar({ refresh: makeRefreshState({ handleRefresh: onRefresh }) })
|
||||
|
||||
const button = findRefreshButton(element)
|
||||
;(button.props.onClick as () => void)()
|
||||
@@ -124,12 +155,7 @@ describe('FileExplorerToolbar', () => {
|
||||
|
||||
it('shows the repo name in a truncated label', () => {
|
||||
const repoName = 'really-long-repo-name-that-should-not-push-refresh-offscreen'
|
||||
const element = FileExplorerToolbar({
|
||||
repoName,
|
||||
refresh: makeRefreshState(),
|
||||
canCollapseAll: false,
|
||||
onCollapseAll: vi.fn()
|
||||
})
|
||||
const element = makeToolbar({ repoName })
|
||||
|
||||
const label = findRepoNameLabel(element, repoName)
|
||||
|
||||
@@ -139,11 +165,8 @@ describe('FileExplorerToolbar', () => {
|
||||
})
|
||||
|
||||
it('disables the refresh button and shows a spinner while refreshing', () => {
|
||||
const element = FileExplorerToolbar({
|
||||
repoName: 'orca',
|
||||
refresh: makeRefreshState({ isRefreshing: true, showRefreshSpinner: true }),
|
||||
canCollapseAll: false,
|
||||
onCollapseAll: vi.fn()
|
||||
const element = makeToolbar({
|
||||
refresh: makeRefreshState({ isRefreshing: true, showRefreshSpinner: true })
|
||||
})
|
||||
|
||||
const button = findRefreshButton(element)
|
||||
@@ -155,9 +178,7 @@ describe('FileExplorerToolbar', () => {
|
||||
|
||||
it('fires the collapse all action from the icon button', () => {
|
||||
const onCollapseAll = vi.fn()
|
||||
const element = FileExplorerToolbar({
|
||||
repoName: 'orca',
|
||||
refresh: makeRefreshState(),
|
||||
const element = makeToolbar({
|
||||
canCollapseAll: true,
|
||||
onCollapseAll
|
||||
})
|
||||
@@ -171,18 +192,30 @@ describe('FileExplorerToolbar', () => {
|
||||
})
|
||||
|
||||
it('disables collapse all when no directories are expanded', () => {
|
||||
const element = FileExplorerToolbar({
|
||||
repoName: 'orca',
|
||||
refresh: makeRefreshState(),
|
||||
canCollapseAll: false,
|
||||
onCollapseAll: vi.fn()
|
||||
})
|
||||
const element = makeToolbar({ canCollapseAll: false })
|
||||
|
||||
const button = findCollapseAllButton(element)
|
||||
|
||||
expect(button.props.disabled).toBe(true)
|
||||
expect(hasIcon(button, ListCollapse)).toBe(true)
|
||||
})
|
||||
|
||||
it('fires the git ignored visibility toggle from the icon button', () => {
|
||||
const onToggleGitIgnoredFiles = vi.fn()
|
||||
const element = makeToolbar({ onToggleGitIgnoredFiles })
|
||||
|
||||
const button = findGitIgnoredButton(element)
|
||||
;(button.props.onClick as () => void)()
|
||||
|
||||
expect(onToggleGitIgnoredFiles).toHaveBeenCalledTimes(1)
|
||||
expect(hasIcon(button, EyeOff)).toBe(true)
|
||||
})
|
||||
|
||||
it('hides the git ignored visibility toggle for non-git folders', () => {
|
||||
const element = makeToolbar({ showGitIgnoredFilesToggle: false })
|
||||
|
||||
expect(queryGitIgnoredButton(element)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('FileExplorerRow collapse folder action', () => {
|
||||
|
||||
@@ -6,12 +6,13 @@ import { useActiveWorktree, useRepoById } from '@/store/selectors'
|
||||
import { basename, dirname } from '@/lib/path'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isGitRepoKind } from '../../../../shared/repo-kind'
|
||||
import { FileExplorerBackgroundMenu } from './FileExplorerBackgroundMenu'
|
||||
import { FileExplorerToolbar } from './FileExplorerToolbar'
|
||||
import { FileExplorerTreeStatus } from './FileExplorerTreeStatus'
|
||||
import { FileExplorerVirtualRows } from './FileExplorerVirtualRows'
|
||||
import { splitPathSegments } from './path-tree'
|
||||
import { buildFolderStatusMap, buildIgnoredSet, buildStatusMap } from './status-display'
|
||||
import { buildFolderStatusMap, buildStatusMap } from './status-display'
|
||||
import { useFileDeletion } from './useFileDeletion'
|
||||
import { useFileExplorerAutoReveal } from './useFileExplorerAutoReveal'
|
||||
import { useFileExplorerHandlers } from './useFileExplorerHandlers'
|
||||
@@ -26,6 +27,7 @@ import { useFileExplorerManualRefresh } from './useFileExplorerManualRefresh'
|
||||
import { useFileExplorerTree } from './useFileExplorerTree'
|
||||
import { useFileExplorerWatch } from './useFileExplorerWatch'
|
||||
import { useFileExplorerSelection } from './useFileExplorerSelection'
|
||||
import { useFileExplorerGitIgnoredRows } from './useFileExplorerGitIgnoredRows'
|
||||
|
||||
function FileExplorerInner(): React.JSX.Element {
|
||||
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
|
||||
@@ -42,12 +44,12 @@ function FileExplorerInner(): React.JSX.Element {
|
||||
const pinFile = useAppStore((s) => s.pinFile)
|
||||
const activeFileId = useAppStore((s) => s.activeFileId)
|
||||
const gitStatusByWorktree = useAppStore((s) => s.gitStatusByWorktree)
|
||||
const showGitIgnoredFiles = useAppStore((s) => s.settings?.showGitIgnoredFiles ?? true)
|
||||
const openFiles = useAppStore((s) => s.openFiles)
|
||||
const closeFile = useAppStore((s) => s.closeFile)
|
||||
|
||||
const worktreePath = activeWorktree?.path ?? null
|
||||
const repoName = activeRepo?.displayName ?? (worktreePath ? basename(worktreePath) : '')
|
||||
const activeRepoSupportsGit = activeRepo ? isGitRepoKind(activeRepo) : false
|
||||
|
||||
const expanded = useMemo(
|
||||
() =>
|
||||
@@ -59,7 +61,6 @@ function FileExplorerInner(): React.JSX.Element {
|
||||
dirCache,
|
||||
setDirCache,
|
||||
flatRows,
|
||||
rowsByPath,
|
||||
rootCache,
|
||||
rootError,
|
||||
loadDir,
|
||||
@@ -67,6 +68,13 @@ function FileExplorerInner(): React.JSX.Element {
|
||||
refreshDir,
|
||||
resetAndLoad
|
||||
} = useFileExplorerTree(worktreePath, expanded, activeWorktreeId)
|
||||
const {
|
||||
visibleFlatRows,
|
||||
rowsByPath,
|
||||
ignoredByRelativePath,
|
||||
showGitIgnoredFiles,
|
||||
toggleGitIgnoredFiles
|
||||
} = useFileExplorerGitIgnoredRows(activeWorktreeId, worktreePath, flatRows, activeRepoSupportsGit)
|
||||
const manualRefresh = useFileExplorerManualRefresh(refreshTree)
|
||||
const canCollapseAll = expanded.size > 0
|
||||
const handleCollapseAll = useCallback(() => {
|
||||
@@ -93,7 +101,7 @@ function FileExplorerInner(): React.JSX.Element {
|
||||
selectRowWithModifiers,
|
||||
preserveSelectionForContextMenu,
|
||||
copyPathsForNode
|
||||
} = useFileExplorerSelection(flatRows, isMac)
|
||||
} = useFileExplorerSelection(visibleFlatRows, isMac)
|
||||
|
||||
const clearFlashTimeout = useCallback(() => {
|
||||
if (flashTimeoutRef.current !== null) {
|
||||
@@ -106,15 +114,8 @@ function FileExplorerInner(): React.JSX.Element {
|
||||
() => (activeWorktreeId ? (gitStatusByWorktree[activeWorktreeId] ?? []) : []),
|
||||
[activeWorktreeId, gitStatusByWorktree]
|
||||
)
|
||||
const ignoredPaths = useAppStore((s) =>
|
||||
activeWorktreeId ? (s.gitIgnoredPathsByWorktree[activeWorktreeId] ?? null) : null
|
||||
)
|
||||
const statusByRelativePath = useMemo(() => buildStatusMap(entries), [entries])
|
||||
const folderStatusByRelativePath = useMemo(() => buildFolderStatusMap(entries), [entries])
|
||||
const ignoredByRelativePath = useMemo(
|
||||
() => (showGitIgnoredFiles ? buildIgnoredSet(ignoredPaths ?? undefined) : new Set<string>()),
|
||||
[ignoredPaths, showGitIgnoredFiles]
|
||||
)
|
||||
|
||||
const { deleteShortcutLabel, requestDelete } = useFileDeletion({
|
||||
activeWorktreeId,
|
||||
@@ -199,7 +200,7 @@ function FileExplorerInner(): React.JSX.Element {
|
||||
activeWorktreeId,
|
||||
worktreePath,
|
||||
expanded,
|
||||
flatRows,
|
||||
flatRows: visibleFlatRows,
|
||||
scrollRef,
|
||||
refreshDir
|
||||
})
|
||||
@@ -226,7 +227,7 @@ function FileExplorerInner(): React.JSX.Element {
|
||||
setSelectedPath: setSingleSelectedPath
|
||||
})
|
||||
|
||||
const totalCount = flatRows.length + (inlineInputIndex >= 0 ? 1 : 0)
|
||||
const totalCount = visibleFlatRows.length + (inlineInputIndex >= 0 ? 1 : 0)
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: totalCount,
|
||||
@@ -239,9 +240,9 @@ function FileExplorerInner(): React.JSX.Element {
|
||||
return '__inline_input__'
|
||||
}
|
||||
const rowIndex = index > inlineInputIndex ? index - 1 : index
|
||||
return flatRows[rowIndex]?.path ?? `__fallback_${index}`
|
||||
return visibleFlatRows[rowIndex]?.path ?? `__fallback_${index}`
|
||||
}
|
||||
return flatRows[index]?.path ?? `__fallback_${index}`
|
||||
return visibleFlatRows[index]?.path ?? `__fallback_${index}`
|
||||
}
|
||||
})
|
||||
|
||||
@@ -254,7 +255,7 @@ function FileExplorerInner(): React.JSX.Element {
|
||||
dirCache,
|
||||
rootCache,
|
||||
rowsByPath,
|
||||
flatRows,
|
||||
flatRows: visibleFlatRows,
|
||||
loadDir,
|
||||
setSelectedPath: setSingleSelectedPath,
|
||||
setFlashingPath,
|
||||
@@ -269,7 +270,7 @@ function FileExplorerInner(): React.JSX.Element {
|
||||
pendingExplorerReveal,
|
||||
openFiles,
|
||||
rowsByPath,
|
||||
flatRows,
|
||||
flatRows: visibleFlatRows,
|
||||
setSelectedPath: setSingleSelectedPath,
|
||||
virtualizer
|
||||
})
|
||||
@@ -283,7 +284,7 @@ function FileExplorerInner(): React.JSX.Element {
|
||||
const selectedNode = selectedPath ? (rowsByPath.get(selectedPath) ?? null) : null
|
||||
useFileExplorerKeys({
|
||||
containerRef: explorerShellRef,
|
||||
flatRows,
|
||||
flatRows: visibleFlatRows,
|
||||
inlineInput,
|
||||
selectedPaths,
|
||||
selectedNode,
|
||||
@@ -302,7 +303,7 @@ function FileExplorerInner(): React.JSX.Element {
|
||||
|
||||
const handleDuplicate = useFileDuplicate({ activeWorktreeId, worktreePath, refreshDir })
|
||||
const handleRowClick = useCallback(
|
||||
(node: (typeof flatRows)[number], event: React.MouseEvent<HTMLButtonElement>) =>
|
||||
(node: (typeof visibleFlatRows)[number], event: React.MouseEvent<HTMLButtonElement>) =>
|
||||
selectRowWithModifiers(node, event, handleClick),
|
||||
[handleClick, selectRowWithModifiers]
|
||||
)
|
||||
@@ -328,7 +329,7 @@ function FileExplorerInner(): React.JSX.Element {
|
||||
// and empty states so the data-native-file-drop-target marker is always
|
||||
// present. Without this, external file drops would have no target surface
|
||||
// when the tree is empty, still loading, or showing a read error.
|
||||
const isEmptyState = flatRows.length === 0 && !inlineInput
|
||||
const isEmptyState = visibleFlatRows.length === 0 && !inlineInput
|
||||
const isLoading = isEmptyState && (rootCache?.loading ?? true)
|
||||
const hasError = isEmptyState && !isLoading && !!rootError
|
||||
const isEmpty = isEmptyState && !isLoading && !hasError
|
||||
@@ -342,6 +343,9 @@ function FileExplorerInner(): React.JSX.Element {
|
||||
refresh={manualRefresh}
|
||||
canCollapseAll={canCollapseAll}
|
||||
onCollapseAll={handleCollapseAll}
|
||||
showGitIgnoredFilesToggle={activeRepoSupportsGit}
|
||||
showGitIgnoredFiles={showGitIgnoredFiles}
|
||||
onToggleGitIgnoredFiles={toggleGitIgnoredFiles}
|
||||
/>
|
||||
<ScrollArea
|
||||
className={cn(
|
||||
@@ -396,7 +400,7 @@ function FileExplorerInner(): React.JSX.Element {
|
||||
<FileExplorerVirtualRows
|
||||
virtualizer={virtualizer}
|
||||
inlineInputIndex={inlineInputIndex}
|
||||
flatRows={flatRows}
|
||||
flatRows={visibleFlatRows}
|
||||
inlineInput={inlineInput}
|
||||
handleInlineSubmit={handleInlineSubmit}
|
||||
dismissInlineInput={dismissInlineInput}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { ListCollapse, Loader2, RefreshCw } from 'lucide-react'
|
||||
import { Eye, EyeOff, ListCollapse, Loader2, RefreshCw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
@@ -12,14 +12,21 @@ type FileExplorerToolbarProps = {
|
||||
}
|
||||
canCollapseAll: boolean
|
||||
onCollapseAll: () => void
|
||||
showGitIgnoredFilesToggle: boolean
|
||||
showGitIgnoredFiles: boolean
|
||||
onToggleGitIgnoredFiles: () => void
|
||||
}
|
||||
|
||||
export function FileExplorerToolbar({
|
||||
repoName,
|
||||
refresh,
|
||||
canCollapseAll,
|
||||
onCollapseAll
|
||||
onCollapseAll,
|
||||
showGitIgnoredFilesToggle,
|
||||
showGitIgnoredFiles,
|
||||
onToggleGitIgnoredFiles
|
||||
}: FileExplorerToolbarProps): React.JSX.Element {
|
||||
const gitIgnoredLabel = showGitIgnoredFiles ? 'Hide Git Ignored Files' : 'Show Git Ignored Files'
|
||||
return (
|
||||
<div className="flex h-8 min-h-8 items-center gap-2 border-b border-border px-2">
|
||||
<span
|
||||
@@ -28,6 +35,25 @@ export function FileExplorerToolbar({
|
||||
>
|
||||
{repoName}
|
||||
</span>
|
||||
{showGitIgnoredFilesToggle ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label={gitIgnoredLabel}
|
||||
onClick={onToggleGitIgnoredFiles}
|
||||
>
|
||||
{showGitIgnoredFiles ? <EyeOff className="size-3" /> : <Eye className="size-3" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{gitIgnoredLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -42,8 +42,7 @@ describe('refreshGitStatusForWorktree', () => {
|
||||
|
||||
expect(gitStatus).toHaveBeenCalledWith({
|
||||
worktreePath: '/repo',
|
||||
connectionId: 'ssh-1',
|
||||
includeIgnored: true
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
expect(deps.setGitStatus).toHaveBeenCalledWith('wt-1', status)
|
||||
expect(deps.updateWorktreeGitIdentity).toHaveBeenCalledWith('wt-1', {
|
||||
@@ -81,7 +80,7 @@ describe('refreshGitStatusForWorktree', () => {
|
||||
expect(deps.fetchUpstreamStatus).toHaveBeenCalledWith('wt-2', '/repo', 'ssh-2')
|
||||
})
|
||||
|
||||
it('omits ignored-file status when the setting is disabled', async () => {
|
||||
it('leaves ignored-file discovery to the File Explorer instead of status polling', async () => {
|
||||
const status: GitStatusResult = {
|
||||
entries: [],
|
||||
conflictOperation: 'unknown'
|
||||
@@ -91,7 +90,7 @@ describe('refreshGitStatusForWorktree', () => {
|
||||
const deps = makeDeps()
|
||||
|
||||
await refreshGitStatusForWorktree({
|
||||
settings: { activeRuntimeEnvironmentId: null, showGitIgnoredFiles: false },
|
||||
settings: { activeRuntimeEnvironmentId: null },
|
||||
worktreeId: 'wt-3',
|
||||
worktreePath: '/repo',
|
||||
deps
|
||||
|
||||
@@ -22,22 +22,18 @@ export async function refreshGitStatusForWorktree({
|
||||
connectionId,
|
||||
deps
|
||||
}: {
|
||||
settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId' | 'showGitIgnoredFiles'> | null
|
||||
settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null
|
||||
worktreeId: string
|
||||
worktreePath: string
|
||||
connectionId?: string
|
||||
deps: GitStatusRefreshDeps
|
||||
}): Promise<void> {
|
||||
const includeIgnored = settings?.showGitIgnoredFiles ?? true
|
||||
const status = (await getRuntimeGitStatus(
|
||||
{
|
||||
settings,
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
connectionId
|
||||
},
|
||||
{ includeIgnored }
|
||||
)) as GitStatusResult
|
||||
const status = (await getRuntimeGitStatus({
|
||||
settings,
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
connectionId
|
||||
})) as GitStatusResult
|
||||
|
||||
deps.setGitStatus(worktreeId, status)
|
||||
// Why: branch switches can happen inside a terminal. `git status --branch`
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { TreeNode } from './file-explorer-types'
|
||||
import { getVisibleFileExplorerRows } from './useFileExplorerGitIgnoredRows'
|
||||
|
||||
function row(relativePath: string): TreeNode {
|
||||
return {
|
||||
name: relativePath.split('/').at(-1) ?? relativePath,
|
||||
path: `/repo/${relativePath}`,
|
||||
relativePath,
|
||||
isDirectory: false,
|
||||
depth: 0
|
||||
}
|
||||
}
|
||||
|
||||
describe('getVisibleFileExplorerRows', () => {
|
||||
it('keeps ignored files visible when the toggle is on', () => {
|
||||
const rows = [row('src/index.ts'), row('dist/bundle.js')]
|
||||
|
||||
expect(getVisibleFileExplorerRows(rows, new Set(['dist']), true)).toBe(rows)
|
||||
})
|
||||
|
||||
it('filters ignored files and descendants when the toggle is off', () => {
|
||||
const rows = [
|
||||
row('src/index.ts'),
|
||||
row('dist'),
|
||||
row('dist/bundle.js'),
|
||||
row('dist2/bundle.js'),
|
||||
row('.env')
|
||||
]
|
||||
|
||||
expect(
|
||||
getVisibleFileExplorerRows(rows, new Set(['dist', '.env']), false).map((r) => r.relativePath)
|
||||
).toEqual(['src/index.ts', 'dist2/bundle.js'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { getRuntimeGitIgnoredPaths } from '@/runtime/runtime-git-client'
|
||||
import type { TreeNode } from './file-explorer-types'
|
||||
import { buildIgnoredSet, isPathIgnored } from './status-display'
|
||||
|
||||
export function getVisibleFileExplorerRows(
|
||||
flatRows: TreeNode[],
|
||||
ignoredSet: Set<string>,
|
||||
showGitIgnoredFiles: boolean
|
||||
): TreeNode[] {
|
||||
return showGitIgnoredFiles
|
||||
? flatRows
|
||||
: flatRows.filter((row) => !isPathIgnored(ignoredSet, row.relativePath))
|
||||
}
|
||||
|
||||
export function useFileExplorerGitIgnoredRows(
|
||||
activeWorktreeId: string | null,
|
||||
worktreePath: string | null,
|
||||
flatRows: TreeNode[],
|
||||
activeRepoSupportsGit: boolean
|
||||
): {
|
||||
visibleFlatRows: TreeNode[]
|
||||
rowsByPath: Map<string, TreeNode>
|
||||
ignoredByRelativePath: Set<string>
|
||||
showGitIgnoredFiles: boolean
|
||||
toggleGitIgnoredFiles: () => void
|
||||
} {
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const updateSettings = useAppStore((s) => s.updateSettings)
|
||||
const showGitIgnoredFiles = settings?.showGitIgnoredFiles ?? true
|
||||
const [ignoredPaths, setIgnoredPaths] = useState<string[]>([])
|
||||
const relativePaths = useMemo(() => flatRows.map((row) => row.relativePath), [flatRows])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!activeRepoSupportsGit ||
|
||||
!activeWorktreeId ||
|
||||
!worktreePath ||
|
||||
relativePaths.length === 0
|
||||
) {
|
||||
setIgnoredPaths([])
|
||||
return
|
||||
}
|
||||
|
||||
let canceled = false
|
||||
const connectionId = getConnectionId(activeWorktreeId) ?? undefined
|
||||
void getRuntimeGitIgnoredPaths(
|
||||
{
|
||||
settings: useAppStore.getState().settings,
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath,
|
||||
connectionId
|
||||
},
|
||||
relativePaths
|
||||
)
|
||||
.then((nextIgnoredPaths) => {
|
||||
if (!canceled) {
|
||||
setIgnoredPaths(nextIgnoredPaths)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!canceled) {
|
||||
setIgnoredPaths([])
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
canceled = true
|
||||
}
|
||||
}, [activeRepoSupportsGit, activeWorktreeId, relativePaths, worktreePath])
|
||||
|
||||
const ignoredSet = useMemo(() => buildIgnoredSet(ignoredPaths), [ignoredPaths])
|
||||
const visibleFlatRows = useMemo(
|
||||
() => getVisibleFileExplorerRows(flatRows, ignoredSet, showGitIgnoredFiles),
|
||||
[flatRows, ignoredSet, showGitIgnoredFiles]
|
||||
)
|
||||
const rowsByPath = useMemo(
|
||||
() => new Map(visibleFlatRows.map((row) => [row.path, row])),
|
||||
[visibleFlatRows]
|
||||
)
|
||||
const ignoredByRelativePath = useMemo(
|
||||
() => (showGitIgnoredFiles ? ignoredSet : new Set<string>()),
|
||||
[ignoredSet, showGitIgnoredFiles]
|
||||
)
|
||||
const toggleGitIgnoredFiles = useCallback(() => {
|
||||
void updateSettings({ showGitIgnoredFiles: !showGitIgnoredFiles })
|
||||
}, [showGitIgnoredFiles, updateSettings])
|
||||
|
||||
return {
|
||||
visibleFlatRows,
|
||||
rowsByPath,
|
||||
ignoredByRelativePath,
|
||||
showGitIgnoredFiles,
|
||||
toggleGitIgnoredFiles
|
||||
}
|
||||
}
|
||||
@@ -293,15 +293,14 @@ export function AppearancePane({
|
||||
|
||||
<SearchableSetting
|
||||
title="Show Git-Ignored Files"
|
||||
description="Dim files matched by .gitignore in the file explorer."
|
||||
description="Show files matched by .gitignore in the file explorer."
|
||||
keywords={['git', 'gitignore', 'ignored', 'file explorer', 'sidebar', 'hide']}
|
||||
className="flex items-center justify-between gap-4 px-1 py-2"
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<Label>Show Git-Ignored Files</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Dim files matched by .gitignore in the file explorer. Turn off to skip the extra git
|
||||
status work on large repos.
|
||||
Turn off to hide files matched by .gitignore from the file explorer.
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitchButton
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable max-lines -- Why: runtime git routing tests share compatibility-cache and IPC stubs; splitting would hide cross-environment contract drift. */
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
bulkDiscardRuntimeGitPaths,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
generateRuntimeCommitMessage,
|
||||
getRuntimeGitDiff,
|
||||
getRuntimeGitHistory,
|
||||
getRuntimeGitIgnoredPaths,
|
||||
getRuntimeGitStatus,
|
||||
pushRuntimeGit
|
||||
} from './runtime-git-client'
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client'
|
||||
|
||||
const gitStatus = vi.fn()
|
||||
const gitCheckIgnored = vi.fn()
|
||||
const gitDiff = vi.fn()
|
||||
const gitHistory = vi.fn()
|
||||
const gitBulkStage = vi.fn()
|
||||
@@ -32,6 +35,7 @@ const runtimeCall = vi.fn()
|
||||
beforeEach(() => {
|
||||
clearRuntimeCompatibilityCacheForTests()
|
||||
gitStatus.mockReset()
|
||||
gitCheckIgnored.mockReset()
|
||||
gitDiff.mockReset()
|
||||
gitHistory.mockReset()
|
||||
gitBulkStage.mockReset()
|
||||
@@ -50,6 +54,7 @@ beforeEach(() => {
|
||||
api: {
|
||||
git: {
|
||||
status: gitStatus,
|
||||
checkIgnored: gitCheckIgnored,
|
||||
diff: gitDiff,
|
||||
history: gitHistory,
|
||||
bulkStage: gitBulkStage,
|
||||
@@ -111,6 +116,28 @@ describe('runtime git client', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('checks ignored paths through local git IPC', async () => {
|
||||
gitCheckIgnored.mockResolvedValue(['dist/bundle.js'])
|
||||
|
||||
const result = await getRuntimeGitIgnoredPaths(
|
||||
{
|
||||
settings: { activeRuntimeEnvironmentId: null },
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
connectionId: 'ssh-1'
|
||||
},
|
||||
['dist/bundle.js', 'src/index.ts']
|
||||
)
|
||||
|
||||
expect(gitCheckIgnored).toHaveBeenCalledWith({
|
||||
worktreePath: '/repo',
|
||||
connectionId: 'ssh-1',
|
||||
paths: ['dist/bundle.js', 'src/index.ts']
|
||||
})
|
||||
expect(result).toEqual(['dist/bundle.js'])
|
||||
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses local git IPC for history when no remote runtime is active', async () => {
|
||||
gitHistory.mockResolvedValue({
|
||||
items: [],
|
||||
@@ -219,6 +246,32 @@ describe('runtime git client', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('checks ignored paths through the active runtime environment', async () => {
|
||||
runtimeEnvironmentCall.mockResolvedValue({
|
||||
id: 'rpc-1',
|
||||
ok: true,
|
||||
result: ['dist/bundle.js'],
|
||||
_meta: { runtimeId: 'remote-runtime' }
|
||||
})
|
||||
|
||||
const result = await getRuntimeGitIgnoredPaths(
|
||||
{
|
||||
settings: { activeRuntimeEnvironmentId: 'env-1' },
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo'
|
||||
},
|
||||
['dist/bundle.js']
|
||||
)
|
||||
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
|
||||
selector: 'env-1',
|
||||
method: 'git.checkIgnored',
|
||||
params: { worktree: 'wt-1', paths: ['dist/bundle.js'] },
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
expect(result).toEqual(['dist/bundle.js'])
|
||||
})
|
||||
|
||||
it('routes bulk mutations and remote operations through the active runtime', async () => {
|
||||
runtimeEnvironmentCall.mockResolvedValue({
|
||||
id: 'rpc-1',
|
||||
|
||||
@@ -86,6 +86,29 @@ export async function getRuntimeGitStatus(
|
||||
)
|
||||
}
|
||||
|
||||
export async function getRuntimeGitIgnoredPaths(
|
||||
context: RuntimeGitContext,
|
||||
paths: string[]
|
||||
): Promise<string[]> {
|
||||
const target = getActiveRuntimeTarget(context.settings)
|
||||
if (paths.length === 0) {
|
||||
return []
|
||||
}
|
||||
if (target.kind === 'local' || !context.worktreeId) {
|
||||
return window.api.git.checkIgnored({
|
||||
worktreePath: context.worktreePath,
|
||||
connectionId: context.connectionId,
|
||||
paths
|
||||
})
|
||||
}
|
||||
return callRuntimeRpc<string[]>(
|
||||
target,
|
||||
'git.checkIgnored',
|
||||
{ worktree: context.worktreeId, paths },
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
}
|
||||
|
||||
export async function getRuntimeGitHistory(
|
||||
context: RuntimeGitContext,
|
||||
options: GitHistoryOptions = {}
|
||||
|
||||
@@ -497,6 +497,10 @@ function createGitApi(): NonNullable<Partial<PreloadApi>['git']> {
|
||||
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
|
||||
return callRuntimeResult('git.status', { worktree: worktree.id, includeIgnored })
|
||||
},
|
||||
checkIgnored: async ({ worktreePath, paths }) => {
|
||||
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
|
||||
return callRuntimeResult('git.checkIgnored', { worktree: worktree.id, paths })
|
||||
},
|
||||
history: async ({ worktreePath, limit, baseRef }) => {
|
||||
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
|
||||
return callRuntimeResult('git.history', { worktree: worktree.id, limit, baseRef })
|
||||
|
||||
Reference in New Issue
Block a user