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
14 changes: 11 additions & 3 deletions e2e/file-explorer-sidebar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ test('file explorer refreshes after external filesystem changes', async ({ creat
await expect(fileExplorerItem(mainWindow, 'old.txt')).toHaveCount(0)
})

test('file explorer context menu supports create rename delete and open terminal here', async ({
test('file explorer context menu supports create rename delete and path actions', async ({
appHarness,
createWorkspace,
mainWindow,
Expand All @@ -154,6 +154,9 @@ test('file explorer context menu supports create rename delete and open terminal
await openFileExplorer(mainWindow)

await fileExplorerItem(mainWindow, 'target-dir').click({ button: 'right' })
await expect(contextMenuItem(mainWindow, 'Copy path')).toBeVisible()
await expect(contextMenuItem(mainWindow, 'Copy relative path')).toBeVisible()
await expect(contextMenuItem(mainWindow, 'Open shell in folder')).toBeVisible()
await expect(contextMenuItem(mainWindow, 'New File')).toBeVisible()
await contextMenuItem(mainWindow, 'New File').click()
await submitFileExplorerNameModal(mainWindow, 'File name', 'created.txt')
Expand Down Expand Up @@ -182,8 +185,8 @@ test('file explorer context menu supports create rename delete and open terminal
const terminalCloses = mainWindow.getByLabel('Close terminal')
await expect(terminalCloses).toHaveCount(1)
await fileExplorerItem(mainWindow, 'created-folder').click({ button: 'right' })
await expect(contextMenuItem(mainWindow, 'Open terminal here')).toBeVisible()
await contextMenuItem(mainWindow, 'Open terminal here').click()
await expect(contextMenuItem(mainWindow, 'Open shell in folder')).toBeVisible()
await contextMenuItem(mainWindow, 'Open shell in folder').click()
await expect(terminalCloses).toHaveCount(2)
})

Expand Down Expand Up @@ -262,6 +265,11 @@ test('git sidebar pane lists grouped working tree changes and opens a diff', asy
const stagedRow = stagedGroup.locator('.git-panel__row').filter({ hasText: 'staged-new.txt' })
await expect(stagedRow).toBeVisible({ timeout: 6000 })
await expect(stagedRow.locator('.git-panel__badge')).toHaveText('A')
await stagedRow.click({ button: 'right' })
await expect(contextMenuItem(mainWindow, 'Copy path')).toBeVisible()
await expect(contextMenuItem(mainWindow, 'Copy relative path')).toBeVisible()
await expect(contextMenuItem(mainWindow, 'Open shell in folder')).toBeVisible()
await mainWindow.keyboard.press('Escape')

// The Changes group lists the modified and untracked files.
const changesGroup = gitPane.locator('.git-panel__group').filter({ hasText: /^Changes/ })
Expand Down
39 changes: 39 additions & 0 deletions e2e/file-viewer-tasks.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,42 @@ test('markdown files expose a Tasks tab with grouped stats and diff progress', a
await expect(mainWindow.locator('.file-tasks__item')).toHaveCount(2)
await expect(mainWindow.locator('.file-tasks__item:not(.file-tasks__item--checked)')).toHaveCount(0)
})

test('file task groups can be sorted recursively', async ({ createWorkspace, mainWindow }) => {
const workspace = await createWorkspace({
name: 'file-viewer-task-sort',
seed: {
files: {
'sorted.md': [
'# Sortable Plan',
'',
'## Parent',
'',
'### Alpha',
'',
'- [ ] Alpha todo',
'',
'### Zebra',
'',
'- [x] Zebra done',
'',
].join('\n'),
},
},
})

await setProjectRoot(mainWindow, workspace.rootDir)
await openFileExplorer(mainWindow)
await fileExplorerItem(mainWindow, 'sorted.md').dblclick()
await mainWindow.getByRole('tab', { name: 'Tasks' }).click()

const titles = mainWindow.locator('.file-tasks__section-title')
await expect(titles.filter({ hasText: 'Zebra' })).toBeVisible()
await expect(titles.filter({ hasText: 'Alpha' })).toBeVisible()
let sectionTitles = await titles.allTextContents()
expect(sectionTitles.indexOf('Zebra')).toBeLessThan(sectionTitles.indexOf('Alpha'))

await mainWindow.getByLabel('Sort task groups').selectOption('name')
sectionTitles = await titles.allTextContents()
expect(sectionTitles.indexOf('Alpha')).toBeLessThan(sectionTitles.indexOf('Zebra'))
})
92 changes: 89 additions & 3 deletions e2e/folder-panel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ test('folder panel supports view modes navigation and refresh', async ({ createW
const workspace = await createWorkspace({
name: 'folder-panel',
seed: {
directories: ['gallery-folder'],
directories: ['gallery-folder', 'gallery-folder/sub'],
files: {
'gallery-folder/inner.txt': 'inside folder\n',
'gallery-folder/sub/nested.txt': 'nested contents\n',
'list-file.txt': 'folder row\n',
'tiny.png': Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9pJsteUAAAAASUVORK5CYII=',
Expand All @@ -37,6 +38,32 @@ test('folder panel supports view modes navigation and refresh', async ({ createW

await folderViewButton(mainWindow, 'List').click()
await expect(mainWindow.locator('.folder-viewer__list')).toBeVisible()
const listHeader = mainWindow.locator('.folder-viewer__list-header')
await expect(listHeader).toContainText('Name')
await expect(listHeader).toContainText('Type')
await expect(listHeader).toContainText('Size')
await expect(listHeader).toContainText('Date Modified')
await expect(listHeader).toContainText('Date Created')
await expect(listHeader).toContainText('Permissions')
const innerRow = mainWindow.locator('.folder-viewer__list-row').filter({ hasText: 'inner.txt' })
await expect(innerRow).toContainText('text/plain')
await expect(innerRow).toContainText('14 B')
await expect(innerRow).toContainText(/-r[w-][x-]/)
const subRow = mainWindow.locator('.folder-viewer__list-row').filter({ hasText: 'sub' })
await expect(subRow).toContainText('folder')
await expect(subRow).toContainText('16 B')

const upRow = mainWindow.locator('.folder-viewer__list-row--up')
await expect(upRow).toBeVisible()
await upRow.dblclick()
const workspaceName = workspace.rootDir.split('/').filter(Boolean).pop() as string
await expect(mainWindow.locator('.folder-viewer__title')).toHaveText(workspaceName)

await mainWindow.locator('.folder-viewer__path-button').click()
const pathInput = mainWindow.locator('.folder-viewer__path-input')
await pathInput.fill(workspace.path('gallery-folder'))
await pathInput.press('Enter')
await expect(mainWindow.locator('.folder-viewer__title')).toHaveText('gallery-folder')

await folderViewButton(mainWindow, 'Tree').click()
await expect(mainWindow.locator('.folder-viewer__tree')).toBeVisible()
Expand All @@ -53,6 +80,16 @@ test('folder panel supports view modes navigation and refresh', async ({ createW
await expect(mainWindow.locator('.folder-viewer__card').filter({ hasText: 'fresh.txt' })).toBeVisible()

await folderViewButton(mainWindow, 'List').click()
const rowNames = mainWindow.locator(
'.folder-viewer__list-row:not(.folder-viewer__list-header):not(.folder-viewer__list-row--up) .folder-viewer__list-name',
)
await expect(rowNames.nth(0)).toContainText('sub')
await expect(rowNames.nth(1)).toContainText('fresh.txt')
await mainWindow.locator('.folder-viewer__list-sort').filter({ hasText: 'Name' }).click()
await expect(rowNames.nth(1)).toContainText('inner.txt')
await mainWindow.locator('.folder-viewer__list-sort').filter({ hasText: 'Name' }).click()
await expect(rowNames.nth(1)).toContainText('fresh.txt')

await mainWindow.locator('.folder-viewer__list-row').filter({ hasText: 'fresh.txt' }).dblclick()
await expect(mainWindow.locator('.file-preview-text')).toContainText('after refresh')
})
Expand Down Expand Up @@ -90,6 +127,12 @@ test('folder panel aggregates markdown tasks recursively', async ({ createWorksp
await expect(mainWindow.locator('.folder-tasks__file-header').filter({ hasText: 'plan.md' })).toContainText('.')
await expect(mainWindow.locator('.folder-tasks__file-header').filter({ hasText: 'roadmap.md' })).toContainText('nested')
await expect(mainWindow.locator('.folder-tasks')).not.toContainText('Ignored todo')
await mainWindow.locator('.folder-tasks__file-header').filter({ hasText: 'plan.md' }).click({ button: 'right' })
await expect(contextMenuItem(mainWindow, 'Copy path')).toBeVisible()
await expect(contextMenuItem(mainWindow, 'Copy relative path')).toBeVisible()
await expect(contextMenuItem(mainWindow, 'Open shell in folder')).toBeVisible()
await expect(contextMenuItem(mainWindow, 'Reveal in OS')).toBeVisible()
await mainWindow.keyboard.press('Escape')

await workspace.writeText('work/nested/fresh.md', ['# Fresh', '', '- [ ] Watched nested task', ''].join('\n'))
await expect(mainWindow.locator('.file-tasks__summary')).toContainText('3 remaining')
Expand All @@ -101,6 +144,46 @@ test('folder panel aggregates markdown tasks recursively', async ({ createWorksp
await expect(mainWindow.locator('.file-tasks__summary')).toContainText('1 remaining')
})

test('folder task groups can be sorted recursively', async ({ createWorkspace, mainWindow }) => {
const workspace = await createWorkspace({
name: 'folder-panel-task-sort',
seed: {
directories: ['work'],
files: {
'work/sorted.md': [
'# Sortable Plan',
'',
'## Parent',
'',
'### Alpha',
'',
'- [ ] Alpha todo',
'',
'### Zebra',
'',
'- [x] Zebra done',
'',
].join('\n'),
},
},
})

await setProjectRoot(mainWindow, workspace.rootDir)
await openFileExplorer(mainWindow)
await fileExplorerItem(mainWindow, 'work').dblclick()
await folderViewButton(mainWindow, 'Tasks').click()

const titles = mainWindow.locator('.file-tasks__section-title')
await expect(titles.filter({ hasText: 'Zebra' })).toBeVisible()
await expect(titles.filter({ hasText: 'Alpha' })).toBeVisible()
let sectionTitles = await titles.allTextContents()
expect(sectionTitles.indexOf('Zebra')).toBeLessThan(sectionTitles.indexOf('Alpha'))

await mainWindow.getByLabel('Sort task groups').selectOption('name')
sectionTitles = await titles.allTextContents()
expect(sectionTitles.indexOf('Alpha')).toBeLessThan(sectionTitles.indexOf('Zebra'))
})

test('folder panel context menu mirrors file operations', async ({ createWorkspace, mainWindow }) => {
const workspace = await createWorkspace({
name: 'folder-panel-ops',
Expand All @@ -120,6 +203,9 @@ test('folder panel context menu mirrors file operations', async ({ createWorkspa
await actionsDirSummary.click()

await actionsDirSummary.click({ button: 'right' })
await expect(contextMenuItem(mainWindow, 'Copy path')).toBeVisible()
await expect(contextMenuItem(mainWindow, 'Copy relative path')).toBeVisible()
await expect(contextMenuItem(mainWindow, 'Open shell in folder')).toBeVisible()
await expect(contextMenuItem(mainWindow, 'New File')).toBeVisible()
await contextMenuItem(mainWindow, 'New File').click()
await submitFileExplorerNameModal(mainWindow, 'File name', 'panel-created.txt')
Expand All @@ -143,7 +229,7 @@ test('folder panel context menu mirrors file operations', async ({ createWorkspa
const terminalCloses = mainWindow.getByLabel('Close terminal')
await expect(terminalCloses).toHaveCount(1)
await actionsDirSummary.click({ button: 'right' })
await expect(contextMenuItem(mainWindow, 'Open terminal here')).toBeVisible()
await contextMenuItem(mainWindow, 'Open terminal here').click()
await expect(contextMenuItem(mainWindow, 'Open shell in folder')).toBeVisible()
await contextMenuItem(mainWindow, 'Open shell in folder').click()
await expect(terminalCloses).toHaveCount(2)
})
87 changes: 87 additions & 0 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import type {
EditWindowState,
FileExplorerEntry,
FileSearchResult,
FolderSizeProgress,
FolderSizeResult,
ProjectEditWindowDraft,
ProjectEditWindowResult,
TerminalRecordingStartMetadata,
Expand Down Expand Up @@ -616,10 +618,14 @@ async function readDirectoryEntries(dirPath: string): Promise<FileExplorerEntry[
: linkStats

return {
createdAtMs: Number.isFinite(stats.birthtimeMs) ? stats.birthtimeMs : null,
isDirectory: stats.isDirectory(),
isSymbolicLink: linkStats.isSymbolicLink(),
mode: stats.mode,
modifiedAtMs: Number.isFinite(stats.mtimeMs) ? stats.mtimeMs : null,
name: entry.name,
path: entryPath,
size: stats.size,
} satisfies FileExplorerEntry
}),
)
Expand All @@ -635,6 +641,76 @@ async function readDirectoryEntries(dirPath: string): Promise<FileExplorerEntry[
return items
}

const folderSizeJobs = new Map<string, { cancelled: boolean }>()

async function runFolderSizeJob(
sender: Electron.WebContents,
payload: { jobId: string; path: string },
): Promise<FolderSizeResult> {
const job = { cancelled: false }
folderSizeJobs.set(payload.jobId, job)

let size = 0
let entryCount = 0
let lastProgressAt = Date.now()
const pendingDirectories = [resolveExplorerPath(payload.path)]

try {
while (pendingDirectories.length > 0) {
if (job.cancelled) {
return { cancelled: true, entryCount, jobId: payload.jobId, size }
}

const directoryPath = pendingDirectories.pop() as string
let entries: Dirent[]
try {
entries = await readdir(directoryPath, { withFileTypes: true })
} catch {
continue
}

for (const entry of entries) {
if (job.cancelled) {
return { cancelled: true, entryCount, jobId: payload.jobId, size }
}

entryCount += 1
const entryPath = path.join(directoryPath, entry.name)

// Never follow symlinks: avoids cycles and double counting.
if (entry.isSymbolicLink()) {
continue
}
if (entry.isDirectory()) {
pendingDirectories.push(entryPath)
continue
}

try {
const stats = await lstat(entryPath)
size += stats.size
} catch {
// unreadable entry: skip it
}
}

const now = Date.now()
if (now - lastProgressAt >= 100 && !sender.isDestroyed()) {
lastProgressAt = now
sender.send('folder-size:progress', {
entryCount,
jobId: payload.jobId,
size,
} satisfies FolderSizeProgress)
}
}

return { cancelled: false, entryCount, jobId: payload.jobId, size }
} finally {
folderSizeJobs.delete(payload.jobId)
}
}

const FILE_SEARCH_IGNORED_DIRECTORIES = new Set([
'.git',
'.hg',
Expand Down Expand Up @@ -2444,6 +2520,17 @@ ipcMain.handle('fs:search-files', async (_event, payload: { rootPath: string; qu
return searchFiles(payload.rootPath, payload.query, payload.limit)
})

ipcMain.handle('fs:calculate-folder-size', (event, payload: { jobId: string; path: string }) => {
return runFolderSizeJob(event.sender, payload)
})

ipcMain.handle('fs:cancel-folder-size', (_event, payload: { jobId: string }) => {
const job = folderSizeJobs.get(payload.jobId)
if (job) {
job.cancelled = true
}
})

ipcMain.handle('fs:get-git-statuses', async (_event, payload: { dirPath: string }) => {
return gitDiffService.getExplorerStatuses(payload.dirPath)
})
Expand Down
10 changes: 10 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import type {
QuickPushGenerateRequest,
QuickPushPlan,
FileExplorerWatchEvent,
FolderSizeProgress,
FolderSizeResult,
RemoteAccessStatus,
SettingsChangeMessage,
TerminalActivityMessage,
Expand All @@ -63,6 +65,9 @@ type ElectronListener<T> = (_event: Electron.IpcRendererEvent, payload: T) => vo
contextBridge.exposeInMainWorld('terminay', {
getHomePath: () => ipcRenderer.invoke('fs:get-home-path') as Promise<string>,
listDirectory: (dirPath: string) => ipcRenderer.invoke('fs:list-directory', { dirPath }) as Promise<FileExplorerEntry[]>,
calculateFolderSize: (payload: { jobId: string; path: string }) =>
ipcRenderer.invoke('fs:calculate-folder-size', payload) as Promise<FolderSizeResult>,
cancelFolderSize: (jobId: string) => ipcRenderer.invoke('fs:cancel-folder-size', { jobId }) as Promise<void>,
searchFiles: (options: { rootPath: string; query: string; limit?: number }) =>
ipcRenderer.invoke('fs:search-files', options) as Promise<FileSearchResult[]>,
getFileExplorerGitStatuses: (dirPath: string) =>
Expand Down Expand Up @@ -237,6 +242,11 @@ contextBridge.exposeInMainWorld('terminay', {
ipcRenderer.on('file-explorer:watch-event', wrapper)
return () => ipcRenderer.off('file-explorer:watch-event', wrapper)
},
onFolderSizeProgress: (listener: (message: FolderSizeProgress) => void) => {
const wrapper: ElectronListener<FolderSizeProgress> = (_event, message) => listener(message)
ipcRenderer.on('folder-size:progress', wrapper)
return () => ipcRenderer.off('folder-size:progress', wrapper)
},
onFileWatchEvent: (listener: (message: FileViewerWatchEvent) => void) => {
const wrapper: ElectronListener<FileViewerWatchEvent> = (_event, message) => listener(message)
ipcRenderer.on('file:watch-event', wrapper)
Expand Down
Loading
Loading