diff --git a/e2e/folder-panel.spec.ts b/e2e/folder-panel.spec.ts index e614562..7ee81ac 100644 --- a/e2e/folder-panel.spec.ts +++ b/e2e/folder-panel.spec.ts @@ -57,6 +57,50 @@ test('folder panel supports view modes navigation and refresh', async ({ createW await expect(mainWindow.locator('.file-preview-text')).toContainText('after refresh') }) +test('folder panel aggregates markdown tasks recursively', async ({ createWorkspace, mainWindow }) => { + const workspace = await createWorkspace({ + name: 'folder-panel-tasks', + seed: { + directories: ['work/nested', 'work/dist'], + files: { + 'work/plan.md': ['# Plan', '', '- [x] Root done', '- [ ] Root todo', ''].join('\n'), + 'work/nested/roadmap.md': [ + '# Roadmap', + '', + '## Phase 1', + '', + '- [ ] Nested todo', + '- [x] Nested done', + '', + ].join('\n'), + 'work/dist/ignored.md': ['# Build output', '', '- [ ] Ignored todo', ''].join('\n'), + }, + }, + }) + + await setProjectRoot(mainWindow, workspace.rootDir) + await openFileExplorer(mainWindow) + + await fileExplorerItem(mainWindow, 'work').dblclick() + await folderViewButton(mainWindow, 'Tasks').click() + + await expect(mainWindow.locator('.file-tasks__summary')).toContainText('2 done') + await expect(mainWindow.locator('.file-tasks__summary')).toContainText('2 remaining') + await expect(mainWindow.locator('.file-tasks__summary')).toContainText('2 files') + 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 workspace.writeText('work/nested/fresh.md', ['# Fresh', '', '- [ ] Watched nested task', ''].join('\n')) + await expect(mainWindow.locator('.file-tasks__summary')).toContainText('3 remaining') + await expect(mainWindow.locator('.folder-tasks__file-header').filter({ hasText: 'fresh.md' })).toBeVisible() + + await mainWindow.locator('.folder-tasks__file-header').filter({ hasText: 'roadmap.md' }).dblclick() + await expect(mainWindow.locator('.file-mode-switcher__button--active')).toHaveText('Tasks') + await expect(mainWindow.locator('.file-tasks__summary')).toContainText('1 done') + await expect(mainWindow.locator('.file-tasks__summary')).toContainText('1 remaining') +}) + test('folder panel context menu mirrors file operations', async ({ createWorkspace, mainWindow }) => { const workspace = await createWorkspace({ name: 'folder-panel-ops', diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts index 98fab00..73befbd 100644 --- a/e2e/settings.spec.ts +++ b/e2e/settings.spec.ts @@ -118,6 +118,7 @@ test('normalizes custom file viewer extension defaults', () => { { extension: '.bin', defaultMode: 'hex' }, { extension: '.bad', defaultMode: 'preview' }, ], + folderTaskIgnoredDirectories: defaultTerminalSettings.fileViewer.folderTaskIgnoredDirectories, refreshIntervalSeconds: 9, }) }) diff --git a/package-lock.json b/package-lock.json index 0f0af43..3161af6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@xterm/xterm": "^6.0.0", "dockview": "^6.6.1", "framer-motion": "^12.40.0", + "highlight.js": "^11.11.1", "lucide-react": "^1.17.0", "markdown-it": "^14.2.0", "monaco-editor": "^0.55.1", @@ -4226,6 +4227,15 @@ "node": ">= 0.4" } }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/hono": { "version": "4.12.25", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", diff --git a/package.json b/package.json index 7674d45..fa84cd6 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "@xterm/xterm": "^6.0.0", "dockview": "^6.6.1", "framer-motion": "^12.40.0", + "highlight.js": "^11.11.1", "lucide-react": "^1.17.0", "markdown-it": "^14.2.0", "monaco-editor": "^0.55.1", diff --git a/src/App.tsx b/src/App.tsx index 2f0b42b..930f653 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2483,8 +2483,26 @@ const ProjectWorkspace = forwardRef< if (existingPanelId) { const existingPanel = api.getPanel(existingPanelId); if (existingPanel) { + if (options?.initialMode) { + existingPanel.api.updateParameters({ + ...existingPanel.params, + initialMode: options.initialMode, + }); + } existingPanel.api.setActive(); syncPanelFocusState(); + if (options?.initialMode) { + window.requestAnimationFrame(() => { + window.dispatchEvent( + new CustomEvent('terminay-file-mode-request', { + detail: { + mode: options.initialMode, + path: filePath, + }, + }), + ); + }); + } return; } } @@ -4928,12 +4946,15 @@ const ProjectWorkspace = forwardRef< useEffect(() => { const onOpenFileEvent = (event: Event) => { - const customEvent = event as CustomEvent<{ path?: string }>; + const customEvent = event as CustomEvent<{ + initialMode?: FileViewerMode; + path?: string; + }>; const filePath = customEvent.detail?.path; if (!filePath) { return; } - void openFile(filePath); + void openFile(filePath, { initialMode: customEvent.detail.initialMode }); }; window.addEventListener('terminay-open-file', onOpenFileEvent); diff --git a/src/components/file-viewer/FilePanel.tsx b/src/components/file-viewer/FilePanel.tsx index 4ae4db4..42112b5 100644 --- a/src/components/file-viewer/FilePanel.tsx +++ b/src/components/file-viewer/FilePanel.tsx @@ -136,6 +136,25 @@ export function FilePanel(props: IDockviewPanelProps) { } }, []) + useEffect(() => { + const onModeRequest = (event: Event) => { + const customEvent = event as CustomEvent<{ mode?: FileViewerMode; path?: string }> + if (customEvent.detail?.path !== filePath || !customEvent.detail.mode) { + return + } + + hasSelectedModeRef.current = true + hasAppliedDefaultModeRef.current = true + setMode(customEvent.detail.mode) + sessionStoreRef.current?.setMode(customEvent.detail.mode) + } + + window.addEventListener('terminay-file-mode-request', onModeRequest) + return () => { + window.removeEventListener('terminay-file-mode-request', onModeRequest) + } + }, [filePath]) + const refreshDiff = useCallback( async (targetPath: string, options?: { keepPrevious?: boolean }) => { if (!isMountedRef.current) { diff --git a/src/components/file-viewer/fileViewer.css b/src/components/file-viewer/fileViewer.css index 62d159b..2716cc8 100644 --- a/src/components/file-viewer/fileViewer.css +++ b/src/components/file-viewer/fileViewer.css @@ -329,13 +329,263 @@ } .file-preview-markdown { - line-height: 1.6; + line-height: 1.65; + font-size: 14.5px; + color: rgba(220, 227, 240, 0.86); + max-width: 980px; +} + +.file-preview-markdown > *:first-child { + margin-top: 0; +} + +.file-preview-markdown > *:last-child { + margin-bottom: 0; +} + +.file-preview-markdown p { + margin: 12px 0; } .file-preview-markdown img { max-width: 100%; } +.file-preview-markdown a { + color: #6fc0ff; + text-decoration: none; +} + +.file-preview-markdown a:hover { + text-decoration: underline; +} + +.file-preview-markdown strong { + color: rgba(238, 243, 251, 0.96); + font-weight: 700; +} + +.file-preview-markdown h1, +.file-preview-markdown h2, +.file-preview-markdown h3, +.file-preview-markdown h4, +.file-preview-markdown h5, +.file-preview-markdown h6 { + color: rgba(238, 243, 251, 0.97); + font-weight: 700; + line-height: 1.3; + margin: 28px 0 12px; +} + +.file-preview-markdown h1 { + font-size: 26px; + padding-bottom: 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.file-preview-markdown h2 { + font-size: 21px; + padding-bottom: 6px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.file-preview-markdown h3 { + font-size: 17px; +} + +.file-preview-markdown h4 { + font-size: 15px; +} + +.file-preview-markdown h5, +.file-preview-markdown h6 { + font-size: 13.5px; + color: rgba(196, 206, 224, 0.7); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.file-preview-markdown ul, +.file-preview-markdown ol { + margin: 12px 0; + padding-left: 26px; +} + +.file-preview-markdown li { + margin: 4px 0; +} + +.file-preview-markdown li::marker { + color: rgba(196, 206, 224, 0.55); +} + +.file-preview-markdown hr { + border: 0; + border-top: 1px solid rgba(255, 255, 255, 0.08); + margin: 24px 0; +} + +.file-preview-markdown blockquote { + margin: 16px 0; + padding: 4px 16px; + border-left: 3px solid rgba(87, 183, 255, 0.45); + background: rgba(255, 255, 255, 0.02); + border-radius: 0 8px 8px 0; + color: rgba(196, 206, 224, 0.82); +} + +.file-preview-markdown blockquote p { + margin: 8px 0; +} + +.file-preview-markdown code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.86em; + background: rgba(255, 255, 255, 0.07); + border: 1px solid rgba(255, 255, 255, 0.06); + padding: 0.12em 0.42em; + border-radius: 5px; +} + +.file-preview-markdown pre { + margin: 16px 0; + padding: 14px 16px; + background: rgba(0, 0, 0, 0.28); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 10px; + overflow-x: auto; + line-height: 1.5; +} + +.file-preview-markdown pre code { + background: none; + border: 0; + padding: 0; + font-size: 13px; +} + +.file-preview-markdown .hljs { + color: rgba(220, 226, 240, 0.86); + background: none; +} + +.file-preview-markdown .hljs-keyword, +.file-preview-markdown .hljs-built_in, +.file-preview-markdown .hljs-literal, +.file-preview-markdown .hljs-selector-tag, +.file-preview-markdown .hljs-doctag { + color: #ff8f70; +} + +.file-preview-markdown .hljs-string, +.file-preview-markdown .hljs-regexp, +.file-preview-markdown .hljs-meta .hljs-string, +.file-preview-markdown .hljs-addition { + color: #c7e88d; +} + +.file-preview-markdown .hljs-comment, +.file-preview-markdown .hljs-quote { + color: rgba(173, 189, 212, 0.58); + font-style: italic; +} + +.file-preview-markdown .hljs-number, +.file-preview-markdown .hljs-symbol, +.file-preview-markdown .hljs-bullet { + color: #f7c46c; +} + +.file-preview-markdown .hljs-title, +.file-preview-markdown .hljs-title.function_, +.file-preview-markdown .hljs-section, +.file-preview-markdown .hljs-name, +.file-preview-markdown .hljs-selector-id, +.file-preview-markdown .hljs-selector-class { + color: #7cc7ff; +} + +.file-preview-markdown .hljs-type, +.file-preview-markdown .hljs-class .hljs-title, +.file-preview-markdown .hljs-title.class_, +.file-preview-markdown .hljs-params { + color: #ffd479; +} + +.file-preview-markdown .hljs-attr, +.file-preview-markdown .hljs-attribute, +.file-preview-markdown .hljs-property, +.file-preview-markdown .hljs-variable, +.file-preview-markdown .hljs-template-variable, +.file-preview-markdown .hljs-selector-attr, +.file-preview-markdown .hljs-selector-pseudo, +.file-preview-markdown .hljs-link { + color: #c792ea; +} + +.file-preview-markdown .hljs-meta { + color: rgba(173, 189, 212, 0.7); +} + +.file-preview-markdown .hljs-deletion { + color: #ff8f70; +} + +.file-preview-markdown .hljs-emphasis { + font-style: italic; +} + +.file-preview-markdown .hljs-strong { + font-weight: 700; +} + +.file-preview-markdown__table-wrap { + margin: 18px 0; + overflow-x: auto; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 10px; +} + +.file-preview-markdown table { + width: 100%; + border-collapse: collapse; + font-size: 13.5px; +} + +.file-preview-markdown th, +.file-preview-markdown td { + padding: 9px 14px; + text-align: left; + vertical-align: top; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + border-right: 1px solid rgba(255, 255, 255, 0.05); +} + +.file-preview-markdown th:last-child, +.file-preview-markdown td:last-child { + border-right: 0; +} + +.file-preview-markdown thead th { + background: rgba(255, 255, 255, 0.045); + color: rgba(238, 243, 251, 0.95); + font-weight: 700; + white-space: nowrap; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.file-preview-markdown tbody tr:nth-child(even) { + background: rgba(255, 255, 255, 0.018); +} + +.file-preview-markdown tbody tr:hover { + background: rgba(87, 183, 255, 0.06); +} + +.file-preview-markdown tbody tr:last-child td { + border-bottom: 0; +} + .file-preview-markdown__task-list-item { list-style: none; } @@ -384,53 +634,146 @@ background: rgba(255, 255, 255, 0.07); } -/* Summary */ +/* Summary / hero */ .file-tasks__summary { + padding: 20px 24px 14px; +} + +.file-tasks__hero { + display: flex; + align-items: center; + gap: 22px; +} + +.file-tasks__hero-body { + flex: 1; + min-width: 0; display: flex; flex-direction: column; - gap: 14px; - padding: 20px 24px 18px; + gap: 12px; +} + +.file-tasks__hero-meta { + font-size: 12px; + color: rgba(196, 206, 224, 0.55); } -.file-tasks__summary-row { +/* Progress ring */ +.file-tasks__ring { + position: relative; + flex: none; + width: 78px; + height: 78px; +} + +.file-tasks__ring svg { + display: block; +} + +.file-tasks__ring-track { + stroke: rgba(255, 255, 255, 0.08); +} + +.file-tasks__ring-fill { + stroke: url(#taskRingGradient); + transition: stroke-dashoffset 0.45s ease; +} + +.file-tasks__ring--complete .file-tasks__ring-fill { + stroke: #40c884; + filter: drop-shadow(0 0 5px rgba(64, 200, 132, 0.5)); +} + +.file-tasks__ring-label { + position: absolute; + inset: 0; + display: grid; + place-items: center; +} + +.file-tasks__ring-value { + font-size: 19px; + font-weight: 800; + color: #eef7ff; + font-variant-numeric: tabular-nums; +} + +.file-tasks__ring-check { + display: grid; + place-items: center; + width: 32px; + height: 32px; + border-radius: 50%; + color: #9ff0c4; + background: rgba(64, 200, 132, 0.16); +} + +.file-tasks__ring-check svg { + width: 18px; + height: 18px; +} + +/* Stat tiles */ +.file-tasks__stats { display: flex; + flex-wrap: wrap; align-items: center; - justify-content: space-between; - gap: 16px; + gap: 10px; } -.file-tasks__summary-metric { +.file-tasks__stat { display: flex; - align-items: baseline; - gap: 8px; + flex-direction: column; + gap: 1px; + min-width: 76px; + padding: 8px 14px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.06); + background: rgba(255, 255, 255, 0.03); } -.file-tasks__summary-value { - font-size: 30px; +.file-tasks__stat-value { + font-size: 20px; font-weight: 800; - line-height: 1; + line-height: 1.1; color: #eef7ff; font-variant-numeric: tabular-nums; } -.file-tasks__summary-label { - font-size: 11px; +.file-tasks__stat-label { + font-size: 10.5px; font-weight: 700; text-transform: uppercase; - letter-spacing: 0.1em; + letter-spacing: 0.08em; color: rgba(196, 206, 224, 0.5); } -.file-tasks__summary-counts { - display: flex; - flex-wrap: wrap; - gap: 8px; +.file-tasks__stat--done { + border-color: rgba(64, 200, 132, 0.26); + background: rgba(64, 200, 132, 0.1); +} + +.file-tasks__stat--done .file-tasks__stat-value { + color: #9ff0c4; +} + +.file-tasks__stat--remaining { + border-color: rgba(214, 150, 70, 0.24); + background: rgba(214, 150, 70, 0.1); +} + +.file-tasks__stat--remaining .file-tasks__stat-value { + color: #ffd9a8; +} + +.file-tasks__stat--files .file-tasks__stat-value { + color: #cfe3ff; } .file-tasks__chip { font-size: 12px; font-weight: 700; - padding: 4px 11px; + padding: 5px 11px; border-radius: 999px; border: 1px solid rgba(255, 255, 255, 0.06); color: rgba(220, 226, 240, 0.78); @@ -438,22 +781,56 @@ font-variant-numeric: tabular-nums; } -.file-tasks__chip--done { - color: #9ff0c4; +.file-tasks__chip--diff, +.file-tasks__badge-diff { + color: #8fd9ff; + background: rgba(87, 183, 255, 0.14); + border-color: rgba(87, 183, 255, 0.24); +} + +/* Callouts */ +.file-tasks__callout { + display: flex; + align-items: flex-start; + gap: 10px; + margin: 0 24px 14px; + padding: 11px 14px; + border-radius: 12px; + font-size: 13px; + font-weight: 600; + line-height: 1.45; + border: 1px solid transparent; +} + +.file-tasks__callout-icon { + flex: none; + font-size: 15px; + line-height: 1.3; +} + +.file-tasks__callout-text { + min-width: 0; +} + +.file-tasks__callout--success { + color: #c3f5d9; background: rgba(64, 200, 132, 0.12); - border-color: rgba(64, 200, 132, 0.22); + border-color: rgba(64, 200, 132, 0.26); } -.file-tasks__chip--remaining { - color: #ffd9a8; - background: rgba(214, 150, 70, 0.12); - border-color: rgba(214, 150, 70, 0.22); +.file-tasks__callout--info { + color: #d9e4fb; + background: rgba(118, 148, 210, 0.12); + border-color: rgba(118, 148, 210, 0.22); } -.file-tasks__chip--diff, -.file-tasks__badge-diff { - color: #8fd9ff; - background: rgba(87, 183, 255, 0.14); +.file-tasks__callout--info .file-tasks__callout-icon { + color: #9ff0c4; +} + +.file-tasks__callout--diff { + color: #cdecff; + background: rgba(87, 183, 255, 0.12); border-color: rgba(87, 183, 255, 0.24); } @@ -472,11 +849,14 @@ transition: width 0.25s ease; } +.file-tasks__summary-bar-fill--complete { + background: #40c884; +} + /* Toolbar */ .file-tasks__toolbar { display: flex; align-items: center; - justify-content: space-between; gap: 12px; padding: 0 24px 12px; border-bottom: 1px solid rgba(255, 255, 255, 0.06); @@ -490,6 +870,113 @@ border-radius: 10px; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.05); + flex: none; +} + +.file-tasks__search { + flex: 1 1 auto; + min-width: 120px; + max-width: 340px; + display: flex; + align-items: center; + gap: 7px; + padding: 5px 10px; + border-radius: 9px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); + color: rgba(196, 206, 224, 0.55); + transition: + border-color 0.15s ease, + color 0.15s ease; +} + +.file-tasks__search--active, +.file-tasks__search:focus-within { + border-color: rgba(87, 183, 255, 0.4); + color: rgba(220, 230, 245, 0.85); +} + +.file-tasks__search-icon { + display: flex; + flex: none; +} + +.file-tasks__search-input { + flex: 1; + min-width: 0; + border: 0; + background: transparent; + color: #eef4ff; + font-size: 13px; + outline: none; +} + +.file-tasks__search-input::placeholder { + color: rgba(196, 206, 224, 0.45); +} + +.file-tasks__search-input::-webkit-search-cancel-button { + display: none; +} + +.file-tasks__search-clear { + flex: none; + border: 0; + background: transparent; + color: rgba(196, 206, 224, 0.5); + cursor: pointer; + font-size: 11px; + line-height: 1; + padding: 2px 3px; + border-radius: 5px; +} + +.file-tasks__search-clear:hover { + color: #eef4ff; + background: rgba(255, 255, 255, 0.07); +} + +.file-tasks__toolbar-actions { + display: flex; + align-items: center; + gap: 6px; + flex: none; + margin-left: auto; +} + +.file-tasks__viewtoggle { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 3px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.05); + flex: none; +} + +.file-tasks__viewtoggle-button { + display: inline-flex; + align-items: center; + gap: 6px; + border: 0; + border-radius: 7px; + background: transparent; + color: rgba(220, 226, 240, 0.66); + padding: 5px 11px; + font-size: 12.5px; + font-weight: 700; + cursor: pointer; +} + +.file-tasks__viewtoggle-button:hover { + color: rgba(238, 244, 255, 0.9); +} + +.file-tasks__viewtoggle-button[aria-selected='true'] { + background: rgba(87, 183, 255, 0.15); + color: #eef7ff; + box-shadow: inset 0 0 0 1px rgba(87, 183, 255, 0.18); } .file-tasks__filter-button { @@ -529,6 +1016,12 @@ background: rgba(255, 255, 255, 0.05); } +.file-tasks__action--active { + color: #eef7ff; + background: rgba(87, 183, 255, 0.15); + box-shadow: inset 0 0 0 1px rgba(87, 183, 255, 0.18); +} + /* Tree */ .file-tasks__scroll { flex: 1; @@ -614,7 +1107,10 @@ .file-tasks__badge-count { min-width: 38px; - text-align: right; + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 3px; color: rgba(196, 206, 224, 0.62); } @@ -622,12 +1118,21 @@ color: #9ff0c4; } +.file-tasks__badge-check { + display: inline-flex; + color: #9ff0c4; +} + .file-tasks__badge-diff { padding: 1px 7px; border-radius: 999px; border: 1px solid rgba(87, 183, 255, 0.24); } +.file-tasks__section--complete > .file-tasks__section-header .file-tasks__section-title { + color: rgba(200, 226, 210, 0.82); +} + .file-tasks__section-body { margin-left: 17px; padding-left: 9px; @@ -685,6 +1190,192 @@ box-shadow: inset 2px 0 0 rgba(87, 183, 255, 0.55); } +/* Kanban */ +.file-kanban__board { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; + align-items: start; +} + +.file-kanban__column { + display: flex; + flex-direction: column; + min-width: 0; + padding: 10px; + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.05); + background: rgba(255, 255, 255, 0.02); +} + +.file-kanban__column-header { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 6px 10px; +} + +.file-kanban__column-dot { + flex: none; + width: 8px; + height: 8px; + border-radius: 50%; + background: rgba(154, 166, 189, 0.8); +} + +.file-kanban__column--started .file-kanban__column-dot { + background: #ffc88a; +} + +.file-kanban__column--finished .file-kanban__column-dot { + background: #6fe0a8; +} + +.file-kanban__column-title { + font-size: 12px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.06em; + color: rgba(220, 226, 240, 0.82); +} + +.file-kanban__column-count { + margin-left: auto; + font-size: 11px; + font-weight: 700; + color: rgba(196, 206, 224, 0.6); + background: rgba(255, 255, 255, 0.05); + border-radius: 999px; + padding: 1px 8px; + font-variant-numeric: tabular-nums; +} + +.file-kanban__column-body { + display: flex; + flex-direction: column; + gap: 8px; +} + +.file-kanban__column-empty { + padding: 16px 6px; + text-align: center; + font-size: 12px; + color: rgba(196, 206, 224, 0.28); +} + +.file-kanban__card { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + text-align: left; + padding: 11px 12px; + border-radius: 11px; + background: rgba(255, 255, 255, 0.035); + border: 1px solid rgba(255, 255, 255, 0.06); + border-left: 3px solid rgba(255, 255, 255, 0.14); +} + +button.file-kanban__card { + cursor: pointer; + transition: + background 0.12s ease, + border-color 0.12s ease; +} + +button.file-kanban__card:hover { + background: rgba(255, 255, 255, 0.06); +} + +.file-kanban__card--notStarted { + border-left-color: rgba(154, 166, 189, 0.5); +} + +.file-kanban__card--started { + border-left-color: rgba(255, 200, 130, 0.75); +} + +.file-kanban__card--finished { + border-left-color: rgba(64, 200, 132, 0.75); +} + +.file-kanban__card-crumbs { + font-size: 11px; + color: rgba(196, 206, 224, 0.5); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.file-kanban__card-title { + font-size: 13px; + font-weight: 700; + line-height: 1.35; + color: rgba(232, 238, 250, 0.95); +} + +.file-kanban__card-progress { + display: flex; + align-items: center; + gap: 10px; +} + +.file-kanban__card-progress .file-tasks__track { + flex: 1; + width: auto; +} + +.file-kanban__card-count { + flex: none; + font-size: 11.5px; + font-weight: 700; + color: rgba(196, 206, 224, 0.62); + font-variant-numeric: tabular-nums; +} + +.file-kanban__card--finished .file-kanban__card-count { + color: #9ff0c4; +} + +.file-kanban__lane { + margin-bottom: 18px; +} + +.file-kanban__lane-header { + display: flex; + align-items: baseline; + gap: 10px; + width: 100%; + border: 0; + background: transparent; + text-align: left; + padding: 2px 2px 10px; + cursor: pointer; +} + +.file-kanban__lane-title { + font-size: 13px; + font-weight: 800; + color: #ffffff; +} + +.file-kanban__lane-header:hover .file-kanban__lane-title { + text-decoration: underline; + text-underline-offset: 2px; +} + +.file-kanban__lane-path { + font-size: 11px; + color: rgba(196, 206, 224, 0.45); +} + +.file-kanban__lane-count { + margin-left: auto; + font-size: 11px; + font-weight: 700; + color: rgba(196, 206, 224, 0.55); +} + .file-code-block__line { display: flex; min-height: 20px; diff --git a/src/components/file-viewer/modes/TasksViewer.tsx b/src/components/file-viewer/modes/TasksViewer.tsx index 729e4f0..5253368 100644 --- a/src/components/file-viewer/modes/TasksViewer.tsx +++ b/src/components/file-viewer/modes/TasksViewer.tsx @@ -1,154 +1,38 @@ -import MarkdownIt from 'markdown-it' import { useMemo, useState } from 'react' import type { GitFileDiff } from '../../../types/fileViewer' +import { parseTasks } from '../tasks/parseTasks' import { - type TaskItem, - type TaskSection, - type TaskStats, - computeStats, - parseTasks, -} from '../tasks/parseTasks' - -const inlineMarkdown = new MarkdownIt({ html: false, linkify: true, typographer: true }) - -type TaskFilter = 'all' | 'remaining' | 'done' - -const FILTERS: { id: TaskFilter; label: string }[] = [ - { id: 'all', label: 'All' }, - { id: 'remaining', label: 'Remaining' }, - { id: 'done', label: 'Done' }, -] + KanbanBoard, + NO_COLLAPSE, + SectionNode, + StatTile, + type TaskFilter, + TaskCallout, + TaskHero, + TaskRow, + TaskToolbar, + type TaskView, + buildPredicate, + cardMatchesQuery, + collectCards, + collectSectionIds, + isComplete, + sectionHasVisibleTasks, +} from '../tasks/taskView' type TasksViewerProps = { diff: GitFileDiff | null text: string } -function renderLabel(label: string): { __html: string } { - return { __html: inlineMarkdown.renderInline(label) } -} - -function percent(stats: TaskStats): number { - if (stats.total === 0) { - return 0 - } - return Math.round((stats.completed / stats.total) * 100) -} - -function matchesFilter(task: TaskItem, filter: TaskFilter): boolean { - if (filter === 'remaining') { - return !task.checked - } - if (filter === 'done') { - return task.checked - } - return true -} - -function sectionHasVisibleTasks(section: TaskSection, filter: TaskFilter): boolean { - if (section.tasks.some((task) => matchesFilter(task, filter))) { - return true - } - return section.children.some((child) => sectionHasVisibleTasks(child, filter)) -} - -function collectSectionIds(section: TaskSection, ids: string[] = []): string[] { - for (const child of section.children) { - ids.push(child.id) - collectSectionIds(child, ids) - } - return ids -} - -function StatsBadge({ stats }: { stats: TaskStats }) { - const complete = stats.total > 0 && stats.completed === stats.total - return ( - - {stats.completedInDiff > 0 ? ( - - +{stats.completedInDiff} - - ) : null} - - ) -} - -function TaskRow({ task }: { task: TaskItem }) { - return ( -
  • 0 ? { marginLeft: `${task.depth * 18}px` } : undefined} - > - - -
  • - ) -} - -function SectionNode({ - section, - collapsed, - filter, - onToggle, -}: { - collapsed: Set - filter: TaskFilter - onToggle: (id: string) => void - section: TaskSection -}) { - if (!sectionHasVisibleTasks(section, filter)) { - return null - } - - const stats = computeStats(section) - const isCollapsed = collapsed.has(section.id) - const visibleTasks = section.tasks.filter((task) => matchesFilter(task, filter)) - const childSections = section.children.filter((child) => sectionHasVisibleTasks(child, filter)) - - return ( -
    - - - {isCollapsed ? null : ( -
    - {visibleTasks.length > 0 ? ( -
      - {visibleTasks.map((task) => ( - - ))} -
    - ) : null} - {childSections.map((child) => ( - - ))} -
    - )} -
    - ) -} - export function TasksViewer({ diff, text }: TasksViewerProps) { const tree = useMemo(() => parseTasks(text, diff), [text, diff]) const allSectionIds = useMemo(() => collectSectionIds(tree.root), [tree]) + const cards = useMemo(() => collectCards(tree.root), [tree]) const [collapsed, setCollapsed] = useState>(() => new Set()) const [filter, setFilter] = useState('all') + const [query, setQuery] = useState('') + const [view, setView] = useState('list') const toggle = (id: string) => { setCollapsed((previous) => { @@ -176,47 +60,50 @@ export function TasksViewer({ diff, text }: TasksViewerProps) { } const { stats } = tree - const rootTasks = tree.root.tasks.filter((task) => matchesFilter(task, filter)) - const rootChildren = tree.root.children.filter((child) => sectionHasVisibleTasks(child, filter)) + const isSearching = query.trim().length > 0 + const activeCollapsed = isSearching ? NO_COLLAPSE : collapsed + const predicate = buildPredicate(filter, query) + const rootTasks = tree.root.tasks.filter(predicate) + const rootChildren = tree.root.children.filter((child) => sectionHasVisibleTasks(child, predicate)) const hasVisible = rootTasks.length > 0 || rootChildren.length > 0 + const complete = isComplete(stats) + const visibleCards = cards.filter((card) => cardMatchesQuery(card, query)) + const isKanban = view === 'kanban' return (
    -
    -
    -
    - {percent(stats)}% - complete -
    -
    - {stats.completed} done - {stats.remaining} remaining - {stats.completedInDiff > 0 ? ( - +{stats.completedInDiff} in diff - ) : null} -
    -
    - -
    + + + + + {stats.completedInDiff > 0 ? ( + + +{stats.completedInDiff} in diff + + ) : null} + + + {complete ? ( + + All {stats.total} tasks complete — nothing left to do. + + ) : stats.completedInDiff > 0 ? ( + + {stats.completedInDiff} task{stats.completedInDiff === 1 ? '' : 's'} checked off in your working changes since the + last commit. + + ) : null} -
    -
    - {FILTERS.map((option) => ( - - ))} -
    - {allSectionIds.length > 0 ? ( + + {!isKanban && allSectionIds.length > 0 ? (
    +
    - {!hasVisible ? ( -
    - {filter === 'remaining' ? 'Nothing left — all tasks are complete 🎉' : 'No completed tasks yet.'} -
    - ) : null} - {rootTasks.length > 0 ? ( -
      - {rootTasks.map((task) => ( - + {isKanban ? ( + visibleCards.length > 0 ? ( + + ) : ( +
      + {isSearching ? `No groups match “${query.trim()}”.` : 'No task groups to show.'} +
      + ) + ) : ( + <> + {!hasVisible ? ( +
      + {isSearching + ? `No tasks match “${query.trim()}”.` + : filter === 'remaining' + ? 'Nothing left — all tasks are complete 🎉' + : 'No completed tasks yet.'} +
      + ) : null} + {rootTasks.length > 0 ? ( +
        + {rootTasks.map((task) => ( + + ))} +
      + ) : null} + {rootChildren.map((child) => ( + ))} -
    - ) : null} - {rootChildren.map((child) => ( - - ))} + + )}
    ) diff --git a/src/components/file-viewer/preview/MarkdownPreview.tsx b/src/components/file-viewer/preview/MarkdownPreview.tsx index 87d1c04..6e9e626 100644 --- a/src/components/file-viewer/preview/MarkdownPreview.tsx +++ b/src/components/file-viewer/preview/MarkdownPreview.tsx @@ -1,7 +1,10 @@ +import hljs from 'highlight.js/lib/common' import MarkdownIt from 'markdown-it' -import { useMemo } from 'react' +import { escapeHtml } from 'markdown-it/lib/common/utils.mjs' +import type { RenderRule } from 'markdown-it/lib/renderer.mjs' import type StateCore from 'markdown-it/lib/rules_core/state_core.mjs' import type Token from 'markdown-it/lib/token.mjs' +import { type MouseEvent, useMemo } from 'react' function taskListPlugin(md: MarkdownIt) { md.core.ruler.after('inline', 'task_list_items', (state: StateCore) => { @@ -43,11 +46,40 @@ function taskListPlugin(md: MarkdownIt) { }) } +function tableWrapPlugin(md: MarkdownIt) { + const renderToken: RenderRule = (tokens, index, options, _env, self) => + self.renderToken(tokens, index, options) + const renderTableOpen = md.renderer.rules.table_open ?? renderToken + const renderTableClose = md.renderer.rules.table_close ?? renderToken + + md.renderer.rules.table_open = (tokens, index, options, env, self) => + `
    ${renderTableOpen(tokens, index, options, env, self)}` + + md.renderer.rules.table_close = (tokens, index, options, env, self) => + `${renderTableClose(tokens, index, options, env, self)}
    ` +} + +function highlightCode(code: string, language: string): string { + if (language && hljs.getLanguage(language)) { + try { + const highlighted = hljs.highlight(code, { language, ignoreIllegals: true }).value + return `
    ${highlighted}
    ` + } catch { + // Fall through to the escaped, unhighlighted output below. + } + } + + return `
    ${escapeHtml(code)}
    ` +} + const markdown = new MarkdownIt({ html: false, linkify: true, typographer: true, -}).use(taskListPlugin) + highlight: highlightCode, +}) + .use(taskListPlugin) + .use(tableWrapPlugin) type MarkdownPreviewProps = { basePath: string @@ -68,10 +100,45 @@ function normalizeTaskListMarkers(text: string): string { }) } +function handlePreviewClick(event: MouseEvent) { + if (!(event.target instanceof Element)) { + return + } + + const anchor = event.target.closest('a') + const href = anchor?.getAttribute('href') + + if (!href || href.startsWith('#')) { + return + } + + if (/^(https?|mailto):/i.test(href)) { + event.preventDefault() + void window.terminay?.openExternal(href) + return + } + + if (href.startsWith('file://')) { + event.preventDefault() + try { + const filePath = decodeURIComponent(new URL(href).pathname) + window.dispatchEvent(new CustomEvent('terminay-open-file', { detail: { path: filePath } })) + } catch { + // Ignore malformed file URLs. + } + } +} + export function MarkdownPreview({ basePath, text }: MarkdownPreviewProps) { const html = useMemo(() => { return normalizeRelativeUrls(markdown.render(normalizeTaskListMarkers(text)), basePath) }, [basePath, text]) - return
    + return ( +
    + ) } diff --git a/src/components/file-viewer/tasks/taskView.tsx b/src/components/file-viewer/tasks/taskView.tsx new file mode 100644 index 0000000..a4d3fec --- /dev/null +++ b/src/components/file-viewer/tasks/taskView.tsx @@ -0,0 +1,608 @@ +import MarkdownIt from 'markdown-it' +import type { ReactNode } from 'react' +import type { FileViewerMode } from '../../../types/fileViewer' +import { type TaskItem, type TaskSection, type TaskStats, computeStats } from './parseTasks' + +const inlineMarkdown = new MarkdownIt({ html: false, linkify: true, typographer: true }) + +export type TaskFilter = 'all' | 'remaining' | 'done' + +export const FILTERS: { id: TaskFilter; label: string }[] = [ + { id: 'all', label: 'All' }, + { id: 'remaining', label: 'Remaining' }, + { id: 'done', label: 'Done' }, +] + +/** A stable empty set used to force-expand everything while searching. */ +export const NO_COLLAPSE: ReadonlySet = new Set() + +export type TaskPredicate = (task: TaskItem) => boolean + +export function renderLabel(label: string): { __html: string } { + return { __html: inlineMarkdown.renderInline(label) } +} + +export function percent(stats: TaskStats): number { + if (stats.total === 0) { + return 0 + } + return Math.round((stats.completed / stats.total) * 100) +} + +export function isComplete(stats: TaskStats): boolean { + return stats.total > 0 && stats.completed === stats.total +} + +export function matchesFilter(task: TaskItem, filter: TaskFilter): boolean { + if (filter === 'remaining') { + return !task.checked + } + if (filter === 'done') { + return task.checked + } + return true +} + +/** Build the combined filter + free-text predicate used to drive every list. */ +export function buildPredicate(filter: TaskFilter, query: string): TaskPredicate { + const needle = query.trim().toLowerCase() + return (task) => { + if (!matchesFilter(task, filter)) { + return false + } + if (needle === '') { + return true + } + return task.label.toLowerCase().includes(needle) + } +} + +export function sectionHasVisibleTasks(section: TaskSection, predicate: TaskPredicate): boolean { + if (section.tasks.some(predicate)) { + return true + } + return section.children.some((child) => sectionHasVisibleTasks(child, predicate)) +} + +export function collectSectionIds(section: TaskSection, keyPrefix = '', ids: string[] = []): string[] { + for (const child of section.children) { + ids.push(keyPrefix ? `${keyPrefix}:${child.id}` : child.id) + collectSectionIds(child, keyPrefix, ids) + } + return ids +} + +function CheckGlyph() { + return ( + + ) +} + +/** The hero donut. Rendered once per view as the headline progress widget. */ +export function ProgressRing({ value, complete }: { value: number; complete: boolean }) { + const size = 78 + const stroke = 8 + const radius = (size - stroke) / 2 + const circumference = 2 * Math.PI * radius + const dashOffset = circumference - (Math.min(100, Math.max(0, value)) / 100) * circumference + + return ( +
    + +
    + {complete ? ( + + ) : ( + {value}% + )} +
    +
    + ) +} + +/** A single number-over-label stat card. The `{' '}` keeps "62 done" contiguous for tests. */ +export function StatTile({ tone, value, label }: { tone: 'done' | 'remaining' | 'total' | 'files' | 'diff'; value: ReactNode; label: string }) { + return ( +
    + {value}{' '} + {label} +
    + ) +} + +export function TaskHero({ stats, meta, children }: { stats: TaskStats; meta?: ReactNode; children: ReactNode }) { + const pct = percent(stats) + const complete = isComplete(stats) + return ( +
    +
    + +
    +
    {children}
    + + {meta ?
    {meta}
    : null} +
    +
    +
    + ) +} + +export function TaskCallout({ tone, icon, children }: { tone: 'success' | 'info' | 'diff'; icon: ReactNode; children: ReactNode }) { + return ( +
    + + {children} +
    + ) +} + +function SearchIcon() { + return ( + + ) +} + +function ListGlyph() { + return ( + + ) +} + +function BoardGlyph() { + return ( + + ) +} + +export type TaskView = 'list' | 'kanban' + +const VIEWS: { id: TaskView; label: string; icon: ReactNode }[] = [ + { id: 'list', label: 'List', icon: }, + { id: 'kanban', label: 'Kanban', icon: }, +] + +export function TaskToolbar({ + view, + onViewChange, + filter, + onFilterChange, + query, + onQueryChange, + showFilter = true, + children, +}: { + view: TaskView + onViewChange: (view: TaskView) => void + filter: TaskFilter + onFilterChange: (filter: TaskFilter) => void + query: string + onQueryChange: (query: string) => void + showFilter?: boolean + children?: ReactNode +}) { + return ( +
    +
    + {VIEWS.map((option) => ( + + ))} +
    + {showFilter ? ( +
    + {FILTERS.map((option) => ( + + ))} +
    + ) : null} +
    + + onQueryChange(event.target.value)} + aria-label="Search tasks" + /> + {query ? ( + + ) : null} +
    + {children ?
    {children}
    : null} +
    + ) +} + +export function StatsBadge({ stats }: { stats: TaskStats }) { + const complete = isComplete(stats) + return ( + + {stats.completedInDiff > 0 ? ( + + +{stats.completedInDiff} + + ) : null} + + ) +} + +export function TaskRow({ + task, + documentPath, + onOpenFile, +}: { + task: TaskItem + documentPath?: string + onOpenFile?: (path: string, initialMode?: FileViewerMode) => void +}) { + const label = + return ( +
  • 0 ? { marginLeft: `${task.depth * 18}px` } : undefined} + > + + {onOpenFile && documentPath ? ( + + ) : ( + label + )} +
  • + ) +} + +export function SectionNode({ + section, + collapsed, + predicate, + onToggle, + keyPrefix, + documentPath, + onOpenFile, +}: { + section: TaskSection + collapsed: ReadonlySet + predicate: TaskPredicate + onToggle: (id: string) => void + keyPrefix?: string + documentPath?: string + onOpenFile?: (path: string, initialMode?: FileViewerMode) => void +}) { + if (!sectionHasVisibleTasks(section, predicate)) { + return null + } + + const sectionId = keyPrefix ? `${keyPrefix}:${section.id}` : section.id + const stats = computeStats(section) + const isCollapsed = collapsed.has(sectionId) + const visibleTasks = section.tasks.filter(predicate) + const childSections = section.children.filter((child) => sectionHasVisibleTasks(child, predicate)) + const complete = isComplete(stats) + + return ( +
    + + + {isCollapsed ? null : ( +
    + {visibleTasks.length > 0 ? ( +
      + {visibleTasks.map((task) => ( + + ))} +
    + ) : null} + {childSections.map((child) => ( + + ))} +
    + )} +
    + ) +} + +/* --------------------------------------------------------------------------- + * Kanban + * + * A "card" is the lowest grouping that directly owns tasks — one level up from + * an individual checkbox. Hoisting to the group is what gives us a meaningful + * three-state board: a group with zero done is Not Started, all done is + * Finished, and anything in between is Started. + * ------------------------------------------------------------------------- */ + +export type KanbanStatus = 'notStarted' | 'started' | 'finished' + +export const KANBAN_COLUMNS: { id: KanbanStatus; label: string }[] = [ + { id: 'notStarted', label: 'Not Started' }, + { id: 'started', label: 'Started' }, + { id: 'finished', label: 'Finished' }, +] + +export type TaskCard = { + id: string + title: string + crumbs: string[] + fileName?: string + documentPath?: string + completed: number + total: number + tasks: TaskItem[] +} + +function shallowCount(section: TaskSection): { total: number; completed: number } { + let total = 0 + let completed = 0 + for (const task of section.tasks) { + total += 1 + if (task.checked) { + completed += 1 + } + } + return { total, completed } +} + +export function cardStatus(card: { completed: number; total: number }): KanbanStatus { + if (card.completed === 0) { + return 'notStarted' + } + if (card.completed >= card.total) { + return 'finished' + } + return 'started' +} + +export function collectCards( + root: TaskSection, + options: { documentPath?: string; fileName?: string } = {}, +): TaskCard[] { + const cards: TaskCard[] = [] + const idBase = options.documentPath ? `${options.documentPath}:` : '' + + const pushCard = (section: TaskSection, title: string, crumbs: string[]) => { + const { total, completed } = shallowCount(section) + cards.push({ + id: `${idBase}${section.id}`, + title, + crumbs, + fileName: options.fileName, + documentPath: options.documentPath, + completed, + total, + tasks: section.tasks, + }) + } + + // Tasks placed before any heading form an implicit top-level group. + if (root.tasks.length > 0) { + pushCard(root, options.fileName ?? 'Ungrouped', []) + } + + const walk = (section: TaskSection, crumbs: string[]) => { + for (const child of section.children) { + const childCrumbs = section.title ? [...crumbs, section.title] : crumbs + if (child.tasks.length > 0) { + pushCard(child, child.title ?? 'Ungrouped', childCrumbs) + } + walk(child, childCrumbs) + } + } + walk(root, []) + + return cards +} + +export function cardMatchesQuery(card: TaskCard, query: string): boolean { + const needle = query.trim().toLowerCase() + if (needle === '') { + return true + } + if (card.title.toLowerCase().includes(needle)) { + return true + } + if (card.fileName?.toLowerCase().includes(needle)) { + return true + } + if (card.crumbs.some((crumb) => crumb.toLowerCase().includes(needle))) { + return true + } + return card.tasks.some((task) => task.label.toLowerCase().includes(needle)) +} + +function KanbanCard({ + card, + showFile, + onOpenFile, +}: { + card: TaskCard + showFile: boolean + onOpenFile?: (path: string, initialMode?: FileViewerMode) => void +}) { + const status = cardStatus(card) + const pct = card.total > 0 ? Math.round((card.completed / card.total) * 100) : 0 + const crumbs = showFile && card.fileName ? [card.fileName, ...card.crumbs] : card.crumbs + const interactive = Boolean(onOpenFile && card.documentPath) + + const body = ( + <> + {crumbs.length > 0 ? ( + {crumbs.join(' › ')} + ) : null} + {card.title} + + + + ) + + const className = `file-kanban__card file-kanban__card--${status}` + + if (interactive && onOpenFile && card.documentPath) { + const path = card.documentPath + return ( + + ) + } + + return
    {body}
    +} + +export function KanbanBoard({ + cards, + showFile, + onOpenFile, +}: { + cards: TaskCard[] + showFile: boolean + onOpenFile?: (path: string, initialMode?: FileViewerMode) => void +}) { + const byStatus: Record = { + notStarted: [], + started: [], + finished: [], + } + for (const card of cards) { + byStatus[cardStatus(card)].push(card) + } + + return ( +
    + {KANBAN_COLUMNS.map((column) => { + const columnCards = byStatus[column.id] + return ( +
    +
    +
    +
    + {columnCards.length === 0 ? ( +
    No groups
    + ) : ( + columnCards.map((card) => ( + + )) + )} +
    +
    + ) + })} +
    + ) +} diff --git a/src/components/folder-viewer/FolderPanel.tsx b/src/components/folder-viewer/FolderPanel.tsx index b3c5bb0..009a15c 100644 --- a/src/components/folder-viewer/FolderPanel.tsx +++ b/src/components/folder-viewer/FolderPanel.tsx @@ -9,6 +9,11 @@ import { import { ContextMenu, type ContextMenuItem } from '../ContextMenu'; import type { IDockviewPanelProps } from 'dockview'; import { type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTerminalSettings } from '../../hooks/useTerminalSettings'; +import { terminayFileGateway } from '../../services/fileViewer'; +import type { FileViewerMode } from '../../types/fileViewer'; +import { parseTasks } from '../file-viewer/tasks/parseTasks'; +import { FolderTasksViewer, type FolderTaskDocument } from './FolderTasksViewer'; import type { FolderDirectoryNode, FolderFileNode, @@ -33,8 +38,11 @@ const IMAGE_EXTENSIONS = new Set([ '.webp', ]); +const MARKDOWN_EXTENSIONS = new Set(['.md', '.markdown', '.mdown', '.mkd']); + const VIEW_MODES: Array<{ mode: FolderViewMode; label: string }> = [ { mode: 'tree', label: 'Tree' }, + { mode: 'tasks', label: 'Tasks' }, { mode: 'list', label: 'List' }, { mode: 'thumbnail', label: 'Thumbnail' }, { mode: 'gallery', label: 'Gallery' }, @@ -165,6 +173,37 @@ function toRelativePath(rootPath: string, candidatePath: string): string { return normalizedCandidate; } +function getRelativeDirectory(relativePath: string): string { + const segments = relativePath.split('/').filter(Boolean); + if (segments.length <= 1) { + return '.'; + } + return segments.slice(0, -1).join('/'); +} + +function parseIgnoredDirectoryPatterns(value: string): string[] { + return value + .split(/[\n,]/) + .map((entry) => normalizePath(entry.trim()).toLowerCase()) + .filter(Boolean); +} + +function shouldIgnoreDirectory( + rootPath: string, + entry: { name: string; path: string }, + ignoredPatterns: string[], +): boolean { + const name = entry.name.toLowerCase(); + const relativePath = normalizePath(toRelativePath(rootPath, entry.path)).toLowerCase(); + + return ignoredPatterns.some((pattern) => { + if (pattern.includes('/')) { + return relativePath === pattern || relativePath.startsWith(`${pattern}/`); + } + return name === pattern; + }); +} + function sortEntriesByTypeAndName( a: { isDirectory: boolean; name: string }, b: { isDirectory: boolean; name: string }, @@ -178,6 +217,95 @@ function sortEntriesByTypeAndName( }); } +type FolderTaskScanResult = { + documents: FolderTaskDocument[]; + errorText: string | null; + ignoredDirectoryCount: number; + scannedDirectoryCount: number; + scannedMarkdownCount: number; + watchedDirectories: string[]; +}; + +async function scanFolderTasks( + rootPath: string, + ignoredPatterns: string[], +): Promise { + const documents: FolderTaskDocument[] = []; + const watchedDirectories: string[] = []; + let ignoredDirectoryCount = 0; + let scannedMarkdownCount = 0; + let firstErrorText: string | null = null; + + const scanDirectory = async (directoryPath: string) => { + watchedDirectories.push(directoryPath); + + let entries: Awaited>; + try { + entries = await window.terminay.listDirectory(directoryPath); + } catch (error) { + firstErrorText ??= error instanceof Error ? error.message : String(error); + return; + } + + entries.sort(sortEntriesByTypeAndName); + + for (const entry of entries) { + if (!entry.isDirectory) { + continue; + } + if (entry.isSymbolicLink || shouldIgnoreDirectory(rootPath, entry, ignoredPatterns)) { + ignoredDirectoryCount += 1; + continue; + } + await scanDirectory(entry.path); + } + + for (const entry of entries) { + if (entry.isDirectory || !MARKDOWN_EXTENSIONS.has(getExtension(entry.name))) { + continue; + } + + scannedMarkdownCount += 1; + + try { + const text = await terminayFileGateway.readFileText(entry.path); + const tree = parseTasks(text); + if (tree.stats.total === 0) { + continue; + } + + const relativePath = toRelativePath(rootPath, entry.path); + documents.push({ + name: entry.name, + path: entry.path, + relativeDirectory: getRelativeDirectory(relativePath), + relativePath, + tree, + }); + } catch (error) { + firstErrorText ??= error instanceof Error ? error.message : String(error); + } + } + }; + + await scanDirectory(rootPath); + documents.sort((a, b) => + a.relativePath.localeCompare(b.relativePath, undefined, { + numeric: true, + sensitivity: 'base', + }), + ); + + return { + documents, + errorText: firstErrorText, + ignoredDirectoryCount, + scannedDirectoryCount: watchedDirectories.length, + scannedMarkdownCount, + watchedDirectories, + }; +} + function toFileUrl(path: string): string { const normalized = path.replace(/\\/g, '/'); const prefixed = /^[a-zA-Z]:\//.test(normalized) @@ -421,10 +549,10 @@ function FileGridCard({ ); } -function dispatchOpenFile(path: string) { +function dispatchOpenFile(path: string, initialMode?: FileViewerMode) { window.dispatchEvent( - new CustomEvent<{ path: string }>('terminay-open-file', { - detail: { path }, + new CustomEvent<{ initialMode?: FileViewerMode; path: string }>('terminay-open-file', { + detail: { initialMode, path }, }), ); } @@ -448,11 +576,22 @@ export function FolderPanel( onNewFolder, onOpenTerminal, } = props.params; + const { settings } = useTerminalSettings(); const [treeRoot, setTreeRoot] = useState(null); const [viewMode, setViewMode] = useState('tree'); const [isTreeLoading, setIsTreeLoading] = useState(true); const [treeErrorText, setTreeErrorText] = useState(null); const [refreshNonce, setRefreshNonce] = useState(0); + const [taskScanRefreshNonce, setTaskScanRefreshNonce] = useState(0); + const [taskDocuments, setTaskDocuments] = useState([]); + const [isTaskScanLoading, setIsTaskScanLoading] = useState(false); + const [taskScanErrorText, setTaskScanErrorText] = useState(null); + const [taskWatchedDirectories, setTaskWatchedDirectories] = useState([]); + const [taskScanStats, setTaskScanStats] = useState({ + ignoredDirectoryCount: 0, + scannedDirectoryCount: 0, + scannedMarkdownCount: 0, + }); const previousFocusRef = useRef(null); const treeLoadRequestRef = useRef(0); @@ -479,6 +618,11 @@ export function FolderPanel( }; const folderTitle = useMemo(() => getNameFromPath(folderPath), [folderPath]); + const ignoredDirectoryPatterns = useMemo( + () => parseIgnoredDirectoryPatterns(settings.fileViewer.folderTaskIgnoredDirectories), + [settings.fileViewer.folderTaskIgnoredDirectories], + ); + const folderTaskRefreshIntervalMs = Math.max(1, settings.fileViewer.refreshIntervalSeconds) * 1000; const summaryText = useMemo(() => { if (!treeRoot) { @@ -551,6 +695,103 @@ export function FolderPanel( }; }, [folderPath, refreshNonce]); + useEffect(() => { + if (viewMode !== 'tasks') { + return; + } + + let isMounted = true; + setIsTaskScanLoading(true); + setTaskScanErrorText(null); + + void scanFolderTasks(folderPath, ignoredDirectoryPatterns) + .then((result) => { + if (!isMounted) { + return; + } + setTaskDocuments(result.documents); + setTaskScanErrorText(result.errorText); + setTaskWatchedDirectories(result.watchedDirectories); + setTaskScanStats({ + ignoredDirectoryCount: result.ignoredDirectoryCount, + scannedDirectoryCount: result.scannedDirectoryCount, + scannedMarkdownCount: result.scannedMarkdownCount, + }); + }) + .catch((error) => { + if (!isMounted) { + return; + } + setTaskDocuments([]); + setTaskWatchedDirectories([]); + setTaskScanErrorText(error instanceof Error ? error.message : String(error)); + setTaskScanStats({ + ignoredDirectoryCount: 0, + scannedDirectoryCount: 0, + scannedMarkdownCount: 0, + }); + }) + .finally(() => { + if (isMounted) { + setIsTaskScanLoading(false); + } + }); + + return () => { + isMounted = false; + }; + }, [folderPath, ignoredDirectoryPatterns, taskScanRefreshNonce, viewMode]); + + useEffect(() => { + if (viewMode !== 'tasks' || taskWatchedDirectories.length === 0) { + return; + } + + const watchedDirectories = Array.from(new Set(taskWatchedDirectories)); + const watchedDirectorySet = new Set(watchedDirectories); + let refreshTimeoutId: number | null = null; + let lastRefreshAt = 0; + + const scheduleRefresh = () => { + if (refreshTimeoutId !== null) { + return; + } + + const elapsedSinceLastRefresh = Date.now() - lastRefreshAt; + const delay = + elapsedSinceLastRefresh >= folderTaskRefreshIntervalMs + ? 0 + : folderTaskRefreshIntervalMs - elapsedSinceLastRefresh; + + refreshTimeoutId = window.setTimeout(() => { + refreshTimeoutId = null; + lastRefreshAt = Date.now(); + setTaskScanRefreshNonce((current) => current + 1); + }, delay); + }; + + const unsubscribe = window.terminay.onFileExplorerWatchEvent((event) => { + if (!watchedDirectorySet.has(event.path)) { + return; + } + scheduleRefresh(); + }); + + for (const directoryPath of watchedDirectories) { + void window.terminay.watchDirectory(directoryPath); + } + + return () => { + if (refreshTimeoutId !== null) { + window.clearTimeout(refreshTimeoutId); + } + unsubscribe(); + for (const directoryPath of watchedDirectories) { + void window.terminay.unwatchDirectory(directoryPath); + } + }; + }, [folderTaskRefreshIntervalMs, taskWatchedDirectories, viewMode]); + const handleExpandDirectory = useCallback( (directoryPath: string) => { setTreeRoot((currentRoot) => { @@ -615,6 +856,21 @@ export function FolderPanel( ); const renderBody = () => { + if (viewMode === 'tasks') { + return ( + setTaskScanRefreshNonce((current) => current + 1)} + scannedDirectoryCount={taskScanStats.scannedDirectoryCount} + scannedMarkdownCount={taskScanStats.scannedMarkdownCount} + /> + ); + } + if (isTreeLoading) { return (
    @@ -754,7 +1010,13 @@ export function FolderPanel( diff --git a/src/components/folder-viewer/FolderTasksViewer.tsx b/src/components/folder-viewer/FolderTasksViewer.tsx new file mode 100644 index 0000000..5e715b0 --- /dev/null +++ b/src/components/folder-viewer/FolderTasksViewer.tsx @@ -0,0 +1,449 @@ +import { useMemo, useState } from 'react'; +import type { FileViewerMode } from '../../types/fileViewer'; +import type { TaskSection, TaskStats } from '../file-viewer/tasks/parseTasks'; +import { + KanbanBoard, + NO_COLLAPSE, + SectionNode, + StatTile, + StatsBadge, + type TaskCard, + type TaskFilter, + TaskCallout, + TaskHero, + TaskRow, + TaskToolbar, + type TaskPredicate, + type TaskView, + buildPredicate, + cardMatchesQuery, + collectCards, + collectSectionIds, + isComplete, + percent, + sectionHasVisibleTasks, +} from '../file-viewer/tasks/taskView'; +import '../file-viewer/fileViewer.css'; + +export type FolderTaskDocument = { + name: string; + path: string; + relativeDirectory: string; + relativePath: string; + tree: { + root: TaskSection; + stats: TaskStats; + }; +}; + +type FolderTasksViewerProps = { + documents: FolderTaskDocument[]; + errorText: string | null; + ignoredDirectoryCount: number; + isLoading: boolean; + onOpenFile: (path: string, initialMode?: FileViewerMode) => void; + onRefresh: () => void; + scannedDirectoryCount: number; + scannedMarkdownCount: number; +}; + +function combineStats(documents: FolderTaskDocument[]): TaskStats { + return documents.reduce( + (total, document) => ({ + total: total.total + document.tree.stats.total, + completed: total.completed + document.tree.stats.completed, + remaining: total.remaining + document.tree.stats.remaining, + completedInDiff: + total.completedInDiff + document.tree.stats.completedInDiff, + }), + { total: 0, completed: 0, remaining: 0, completedInDiff: 0 }, + ); +} + +function FileCheckIcon() { + return ( + + ); +} + +function FileDocIcon() { + return ( + + ); +} + +function FileTaskGroup({ + collapsed, + document, + predicate, + onOpenFile, + onToggle, +}: { + collapsed: ReadonlySet; + document: FolderTaskDocument; + predicate: TaskPredicate; + onOpenFile: (path: string, initialMode?: FileViewerMode) => void; + onToggle: (id: string) => void; +}) { + const fileId = `file:${document.path}`; + const isCollapsed = collapsed.has(fileId); + const rootTasks = document.tree.root.tasks.filter(predicate); + const rootChildren = document.tree.root.children.filter((child) => + sectionHasVisibleTasks(child, predicate), + ); + const hasVisible = rootTasks.length > 0 || rootChildren.length > 0; + + if (!hasVisible) { + return null; + } + + const stats = document.tree.stats; + const complete = isComplete(stats); + + return ( +
    + + + {isCollapsed ? null : ( +
    + {rootTasks.length > 0 ? ( +
      + {rootTasks.map((task) => ( + + ))} +
    + ) : null} + {rootChildren.map((child) => ( + + ))} +
    + )} +
    + ); +} + +export function FolderTasksViewer({ + documents, + errorText, + ignoredDirectoryCount, + isLoading, + onOpenFile, + onRefresh, + scannedDirectoryCount, + scannedMarkdownCount, +}: FolderTasksViewerProps) { + const stats = useMemo(() => combineStats(documents), [documents]); + const filesComplete = useMemo( + () => documents.filter((document) => isComplete(document.tree.stats)).length, + [documents], + ); + const allSectionIds = useMemo( + () => + documents.flatMap((document) => [ + `file:${document.path}`, + ...collectSectionIds(document.tree.root, document.path), + ]), + [documents], + ); + const cardsByDocument = useMemo( + () => + documents.map((document) => ({ + document, + cards: collectCards(document.tree.root, { + documentPath: document.path, + fileName: document.name, + }), + })), + [documents], + ); + const allCards = useMemo( + () => cardsByDocument.flatMap((entry) => entry.cards), + [cardsByDocument], + ); + const [collapsed, setCollapsed] = useState>(() => new Set()); + const [filter, setFilter] = useState('all'); + const [query, setQuery] = useState(''); + const [view, setView] = useState('list'); + const [groupByFile, setGroupByFile] = useState(false); + + const toggle = (id: string) => { + setCollapsed((previous) => { + const next = new Set(previous); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + const allCollapsed = + allSectionIds.length > 0 && allSectionIds.every((id) => collapsed.has(id)); + const isSearching = query.trim().length > 0; + const isKanban = view === 'kanban'; + const activeCollapsed = isSearching ? NO_COLLAPSE : collapsed; + const predicate = buildPredicate(filter, query); + const visibleCards = allCards.filter((card) => cardMatchesQuery(card, query)); + const visibleLanes = cardsByDocument + .map((entry) => ({ + document: entry.document, + cards: entry.cards.filter((card) => cardMatchesQuery(card, query)), + })) + .filter((entry) => entry.cards.length > 0); + const hasVisibleDocuments = documents.some((document) => { + const root = document.tree.root; + return ( + root.tasks.some(predicate) || + root.children.some((child) => sectionHasVisibleTasks(child, predicate)) + ); + }); + + if (isLoading && stats.total === 0) { + return ( +
    +
    Scanning folder
    +
    + Looking for markdown checkboxes recursively. +
    +
    + ); + } + + if (errorText && stats.total === 0) { + return ( +
    +
    Unable to scan tasks
    +
    {errorText}
    + +
    + ); + } + + if (stats.total === 0) { + return ( +
    +
    No tasks found
    +
    + No markdown checkboxes were found in this folder. +
    +
    + ); + } + + const complete = isComplete(stats); + + return ( +
    + +
    + {isLoading ? 'Refreshing…' : 'Up to date'} · {scannedMarkdownCount}{' '} + markdown files scanned · {scannedDirectoryCount} folders watched + {ignoredDirectoryCount > 0 + ? ` · ${ignoredDirectoryCount} ignored` + : ''} +
    + {errorText ? ( +
    {errorText}
    + ) : null} + + } + > + + + +
    + + {complete ? ( + + All {stats.total} tasks across {documents.length}{' '} + {documents.length === 1 ? 'file' : 'files'} complete. + + ) : filesComplete > 0 ? ( + + {filesComplete} of {documents.length} files fully complete · {stats.remaining}{' '} + tasks remaining. + + ) : null} + + + {isKanban ? ( + + ) : allSectionIds.length > 0 ? ( + + ) : null} + + + +
    + {isKanban ? ( + visibleCards.length === 0 ? ( +
    + {isSearching + ? `No groups match “${query.trim()}”.` + : 'No task groups to show.'} +
    + ) : groupByFile ? ( + visibleLanes.map((lane) => ( +
    + + +
    + )) + ) : ( + + ) + ) : ( + <> + {!hasVisibleDocuments ? ( +
    + {isSearching + ? `No tasks match “${query.trim()}”.` + : filter === 'remaining' + ? 'Nothing left — all tasks are complete.' + : 'No completed tasks yet.'} +
    + ) : null} + {documents.map((document) => ( + + ))} + + )} +
    +
    + ); +} diff --git a/src/components/folder-viewer/folderViewer.css b/src/components/folder-viewer/folderViewer.css index 3e0fe87..d9bade4 100644 --- a/src/components/folder-viewer/folderViewer.css +++ b/src/components/folder-viewer/folderViewer.css @@ -363,6 +363,141 @@ text-overflow: ellipsis; } +.folder-tasks { + height: 100%; +} + +.folder-tasks__scan-meta { + margin-top: 0; + font-size: 12px; + color: rgba(255, 255, 255, 0.5); +} + +.folder-tasks__scan-error { + margin-top: 6px; + font-size: 12px; + color: #f87171; +} + +.folder-tasks__actions { + display: flex; + align-items: center; + gap: 8px; +} + +.folder-tasks__file { + margin-bottom: 10px; + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 12px; + background: rgba(255, 255, 255, 0.018); + overflow: hidden; +} + +.folder-tasks__file--complete { + border-color: rgba(64, 200, 132, 0.22); + background: rgba(64, 200, 132, 0.04); +} + +.folder-tasks__file-header { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + border: 0; + background: transparent; + color: #ffffff; + text-align: left; + padding: 11px 14px; + cursor: pointer; +} + +.folder-tasks__file-header:hover { + background: rgba(255, 255, 255, 0.03); +} + +.folder-tasks__file-header .file-tasks__badge { + margin-left: 0; +} + +.folder-tasks__file-icon { + flex: none; + display: flex; + color: rgba(196, 206, 224, 0.55); +} + +.folder-tasks__file-icon--complete { + color: #7fe0ab; +} + +.folder-tasks__file-main { + min-width: 0; + flex: 1; + display: flex; + flex-direction: column; + gap: 1px; +} + +.folder-tasks__file-name { + font-size: 13px; + font-weight: 700; + color: #ffffff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.folder-tasks__file-path { + font-size: 11px; + color: rgba(255, 255, 255, 0.42); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.folder-tasks__file-pct { + flex: none; + font-size: 12px; + font-weight: 700; + color: rgba(196, 206, 224, 0.6); + font-variant-numeric: tabular-nums; +} + +.folder-tasks__file-done { + flex: none; + display: inline-flex; + align-items: center; + font-size: 10.5px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #9ff0c4; + background: rgba(64, 200, 132, 0.14); + border: 1px solid rgba(64, 200, 132, 0.26); + padding: 2px 9px; + border-radius: 999px; +} + +.folder-tasks__file-body { + padding: 0 14px 10px; +} + +.folder-tasks__task-link { + min-width: 0; + border: 0; + background: transparent; + color: inherit; + padding: 0; + text-align: left; + cursor: pointer; +} + +.folder-tasks__task-link:hover .file-tasks__label { + color: #ffffff; + text-decoration: underline; + text-decoration-color: rgba(255, 255, 255, 0.35); + text-underline-offset: 2px; +} + @media (max-width: 600px) { .folder-viewer__toolbar-left { flex-direction: column; diff --git a/src/components/folder-viewer/types.ts b/src/components/folder-viewer/types.ts index ef3b3f1..795c87f 100644 --- a/src/components/folder-viewer/types.ts +++ b/src/components/folder-viewer/types.ts @@ -7,7 +7,7 @@ export type FolderPanelInstanceParams = { projectColor?: string; }; -export type FolderViewMode = 'tree' | 'list' | 'thumbnail' | 'gallery'; +export type FolderViewMode = 'tree' | 'tasks' | 'list' | 'thumbnail' | 'gallery'; export type FolderFileNode = { kind: 'file'; diff --git a/src/terminalSettings.ts b/src/terminalSettings.ts index 70cdc88..0fa7945 100644 --- a/src/terminalSettings.ts +++ b/src/terminalSettings.ts @@ -144,6 +144,26 @@ Please: Work autonomously and do not ask me to confirm individual steps.`; +export const DEFAULT_FOLDER_TASK_IGNORED_DIRECTORIES = [ + '.git', + '.hg', + '.svn', + 'node_modules', + 'bower_components', + 'dist', + 'build', + 'out', + '.next', + '.nuxt', + '.cache', + 'coverage', + 'target', + 'vendor', + '.venv', + 'venv', + '__pycache__', +].join('\n'); + export const defaultTerminalSettings: TerminalSettings = { aiTabMetadata: { title: { @@ -208,6 +228,7 @@ export const defaultTerminalSettings: TerminalSettings = { wordSeparator: ' ()[]{}\',"`', fileViewer: { customFileExtensions: [], + folderTaskIgnoredDirectories: DEFAULT_FOLDER_TASK_IGNORED_DIRECTORIES, refreshIntervalSeconds: 5, }, keyboardShortcuts: defaultKeyboardShortcuts, @@ -835,6 +856,25 @@ export const terminalSettingsSections: SettingsSectionDefinition[] = [ 'default tab', ], }), + makeField({ + key: 'fileViewer.folderTaskIgnoredDirectories', + label: 'Folder Tasks ignored folders', + description: + 'Folder names or relative paths to skip when recursively scanning markdown tasks.', + sectionId: 'file-viewer-refresh', + categoryId: 'files', + input: 'textarea', + placeholder: DEFAULT_FOLDER_TASK_IGNORED_DIRECTORIES, + keywords: [ + 'folder', + 'tasks', + 'markdown', + 'ignore', + 'exclude', + 'node_modules', + 'dist', + ], + }), ], }, { @@ -2142,6 +2182,10 @@ export function normalizeTerminalSettings( ) === index, ) : defaultTerminalSettings.fileViewer.customFileExtensions, + folderTaskIgnoredDirectories: + typeof fileViewerInput.folderTaskIgnoredDirectories === 'string' + ? fileViewerInput.folderTaskIgnoredDirectories + : defaultTerminalSettings.fileViewer.folderTaskIgnoredDirectories, refreshIntervalSeconds: clampNumber( Number(fileViewerInput.refreshIntervalSeconds), defaultTerminalSettings.fileViewer.refreshIntervalSeconds, diff --git a/src/types/settings.ts b/src/types/settings.ts index 6cb1873..fcabf95 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -74,6 +74,7 @@ export type SidebarSettings = { export type FileViewerSettings = { customFileExtensions: FileViewerCustomExtensionDefault[]; + folderTaskIgnoredDirectories: string; refreshIntervalSeconds: number; };