From 71dea3764d46fd1561681888f45ba040eb1eb016 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?luoye=E3=82=B9=E3=82=AD?= <100058663+luoye520ww@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:29:31 +0800 Subject: [PATCH 01/17] docs: plan issue 781 document management improvements --- .kun/issue-781-planning.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .kun/issue-781-planning.md diff --git a/.kun/issue-781-planning.md b/.kun/issue-781-planning.md new file mode 100644 index 000000000..60896f60d --- /dev/null +++ b/.kun/issue-781-planning.md @@ -0,0 +1,13 @@ +# Issue #781 implementation plan + +This branch implements the seven document-management usability requests from KunAgent/Kun#781. + +Scope: + +1. More assistant message file path references become clickable, including inline code and code-block path summaries. +2. File preview tabs are kept across same-workspace conversation switches. +3. Each previewed file remembers its own scroll position. +4. File tabs support mouse-wheel switching, closing other tabs, and pinning tabs. +5. The file manager shows recent workspace files and supports modified-time sorting. +6. Files and folders in the file manager can be dragged into the composer as references. +7. The document preview can be expanded into a larger reading overlay and can also be opened in the external editor. From b022928d4a16dbd7b8c4c1b5e41e4114ec386b94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?luoye=E3=82=B9=E3=82=AD?= <100058663+luoye520ww@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:29:51 +0800 Subject: [PATCH 02/17] chore: remove accidental planning note --- .kun/issue-781-planning.md | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 .kun/issue-781-planning.md diff --git a/.kun/issue-781-planning.md b/.kun/issue-781-planning.md deleted file mode 100644 index 60896f60d..000000000 --- a/.kun/issue-781-planning.md +++ /dev/null @@ -1,13 +0,0 @@ -# Issue #781 implementation plan - -This branch implements the seven document-management usability requests from KunAgent/Kun#781. - -Scope: - -1. More assistant message file path references become clickable, including inline code and code-block path summaries. -2. File preview tabs are kept across same-workspace conversation switches. -3. Each previewed file remembers its own scroll position. -4. File tabs support mouse-wheel switching, closing other tabs, and pinning tabs. -5. The file manager shows recent workspace files and supports modified-time sorting. -6. Files and folders in the file manager can be dragged into the composer as references. -7. The document preview can be expanded into a larger reading overlay and can also be opened in the external editor. From 0b1a0982392bb6e8c11fa18f0b1eaf8542284865 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?luoye=E3=82=B9=E3=82=AD?= <100058663+luoye520ww@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:34:46 +0800 Subject: [PATCH 03/17] feat(files): broaden assistant file reference detection --- src/renderer/src/lib/file-references.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/lib/file-references.ts b/src/renderer/src/lib/file-references.ts index 7ae3e3388..46591e0a8 100644 --- a/src/renderer/src/lib/file-references.ts +++ b/src/renderer/src/lib/file-references.ts @@ -20,7 +20,7 @@ type HastNode = { } const FILE_REFERENCE_SCHEME = 'deepseek-file:' -const PATH_PREFIX_BOUNDARY = String.raw`(? Date: Wed, 8 Jul 2026 14:35:05 +0800 Subject: [PATCH 04/17] feat(files): linkify generated file paths in rendered output --- .../src/lib/issue-781-document-usability.ts | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 src/renderer/src/lib/issue-781-document-usability.ts diff --git a/src/renderer/src/lib/issue-781-document-usability.ts b/src/renderer/src/lib/issue-781-document-usability.ts new file mode 100644 index 000000000..30edd0175 --- /dev/null +++ b/src/renderer/src/lib/issue-781-document-usability.ts @@ -0,0 +1,177 @@ +import type { WorkspaceFileTarget } from '@shared/workspace-file' +import { findFileReferences } from './file-references' +import { previewWorkspaceFile, WORKSPACE_FILE_PREVIEW_EVENT, type WorkspaceFilePreviewDetail } from './workspace-file-preview' + +const LINKIFIED_ATTR = 'data-kun-issue781-linkified' +const FILE_PATH_ATTR = 'data-kun-issue781-file-path' +const FILE_LINE_ATTR = 'data-kun-issue781-file-line' +const FILE_COLUMN_ATTR = 'data-kun-issue781-file-column' +const STYLE_ID = 'kun-issue-781-document-usability-style' + +let installed = false +let observer: MutationObserver | null = null +let scanTimer: number | null = null +let lastPreviewTarget: WorkspaceFileTarget | null = null +let lastUserCloseAt = 0 + +function injectStyle(): void { + if (document.getElementById(STYLE_ID)) return + const style = document.createElement('style') + style.id = STYLE_ID + style.textContent = ` + .ds-issue781-file-link { + display: inline; + max-width: 100%; + border: 0; + border-radius: 5px; + background: color-mix(in srgb, var(--ds-accent) 10%, transparent); + color: var(--ds-accent); + cursor: pointer; + font: inherit; + padding: 0 2px; + text-align: inherit; + text-decoration: underline; + text-decoration-color: color-mix(in srgb, var(--ds-accent) 45%, transparent); + text-underline-offset: 2px; + } + .ds-issue781-file-link:hover { + background: color-mix(in srgb, var(--ds-accent) 17%, transparent); + text-decoration-color: var(--ds-accent); + } + ` + document.head.appendChild(style) +} + +function isBlockedTextNode(node: Text): boolean { + const parent = node.parentElement + if (!parent) return true + return Boolean(parent.closest('a, button, textarea, script, style, [contenteditable="true"]')) +} + +function targetFromDataset(element: HTMLElement): WorkspaceFileTarget | null { + const path = element.getAttribute(FILE_PATH_ATTR)?.trim() + if (!path) return null + const line = Number.parseInt(element.getAttribute(FILE_LINE_ATTR) ?? '', 10) + const column = Number.parseInt(element.getAttribute(FILE_COLUMN_ATTR) ?? '', 10) + return { + path, + ...(Number.isFinite(line) && line > 0 ? { line } : {}), + ...(Number.isFinite(column) && column > 0 ? { column } : {}) + } +} + +function recordPreviewTarget(target: WorkspaceFileTarget): void { + lastPreviewTarget = target +} + +function linkifyTextNode(node: Text): void { + if (isBlockedTextNode(node)) return + const text = node.nodeValue ?? '' + const matches = findFileReferences(text) + if (matches.length === 0) return + + const fragment = document.createDocumentFragment() + let cursor = 0 + for (const match of matches) { + if (match.start > cursor) { + fragment.appendChild(document.createTextNode(text.slice(cursor, match.start))) + } + const button = document.createElement('button') + button.type = 'button' + button.className = 'ds-issue781-file-link ds-file-reference-link' + button.setAttribute(LINKIFIED_ATTR, '1') + button.setAttribute(FILE_PATH_ATTR, match.target.path) + if (match.target.line) button.setAttribute(FILE_LINE_ATTR, String(match.target.line)) + if (match.target.column) button.setAttribute(FILE_COLUMN_ATTR, String(match.target.column)) + button.title = match.target.line ? `${match.target.path}:${match.target.line}` : match.target.path + button.textContent = match.text + fragment.appendChild(button) + cursor = match.end + } + if (cursor < text.length) { + fragment.appendChild(document.createTextNode(text.slice(cursor))) + } + node.replaceWith(fragment) +} + +function linkifyContainer(container: ParentNode): void { + const walker = document.createTreeWalker( + container, + NodeFilter.SHOW_TEXT, + { + acceptNode(node) { + if (!(node instanceof Text)) return NodeFilter.FILTER_REJECT + if (!node.nodeValue?.trim()) return NodeFilter.FILTER_REJECT + return isBlockedTextNode(node) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT + } + } + ) + const nodes: Text[] = [] + while (walker.nextNode()) nodes.push(walker.currentNode as Text) + for (const node of nodes) linkifyTextNode(node) +} + +function scanRenderedOutput(): void { + const containers = document.querySelectorAll('.ds-markdown, .ds-code-block-html, .ds-file-preview-code-html') + for (const container of containers) linkifyContainer(container) +} + +function scheduleScan(): void { + if (scanTimer !== null) return + scanTimer = window.setTimeout(() => { + scanTimer = null + scanRenderedOutput() + }, 120) +} + +function restorePreviewIfThreadSwitchClosedIt(): void { + if (!lastPreviewTarget) return + if (Date.now() - lastUserCloseAt < 1200) return + if (document.querySelector('.ds-code-sidebar')) return + window.setTimeout(() => { + if (!lastPreviewTarget || document.querySelector('.ds-code-sidebar')) return + previewWorkspaceFile(lastPreviewTarget) + }, 180) +} + +function onDocumentClick(event: MouseEvent): void { + const target = event.target + if (!(target instanceof HTMLElement)) return + + const fileLink = target.closest(`[${LINKIFIED_ATTR}]`) + if (fileLink instanceof HTMLElement) { + const fileTarget = targetFromDataset(fileLink) + if (!fileTarget) return + event.preventDefault() + event.stopPropagation() + recordPreviewTarget(fileTarget) + previewWorkspaceFile(fileTarget) + return + } + + if (target.closest('.ds-code-sidebar-actions button:last-child')) { + lastUserCloseAt = Date.now() + } +} + +function onPreviewEvent(event: Event): void { + const detail = (event as CustomEvent).detail + if (!detail?.path) return + recordPreviewTarget(detail) +} + +export function installIssue781DocumentUsability(): void { + if (installed || typeof window === 'undefined' || typeof document === 'undefined') return + installed = true + injectStyle() + scanRenderedOutput() + document.addEventListener('click', onDocumentClick, true) + window.addEventListener(WORKSPACE_FILE_PREVIEW_EVENT, onPreviewEvent) + observer = new MutationObserver(() => { + scheduleScan() + restorePreviewIfThreadSwitchClosedIt() + }) + observer.observe(document.body, { childList: true, subtree: true }) +} + +installIssue781DocumentUsability() From 67108eaeaa1be599bd9ac621ae778877d1cd1333 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?luoye=E3=82=B9=E3=82=AD?= <100058663+luoye520ww@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:35:12 +0800 Subject: [PATCH 05/17] feat(files): install document usability hooks --- src/renderer/src/App.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 134daa9c2..89e2f266c 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,5 +1,6 @@ import { lazy, Suspense } from 'react' import { AppErrorBoundary } from './components/AppErrorBoundary' +import './lib/issue-781-document-usability' const AppShell = lazy(() => import('./AppShell')) From f7bb5ac03d243cd989d6c880f82d7487944a2036 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?luoye=E3=82=B9=E3=82=AD?= <100058663+luoye520ww@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:36:18 +0800 Subject: [PATCH 06/17] feat(files): keep and enhance preview tabs --- .../src/lib/issue-781-document-usability.ts | 272 ++++++++++++++++++ 1 file changed, 272 insertions(+) diff --git a/src/renderer/src/lib/issue-781-document-usability.ts b/src/renderer/src/lib/issue-781-document-usability.ts index 30edd0175..015514d2f 100644 --- a/src/renderer/src/lib/issue-781-document-usability.ts +++ b/src/renderer/src/lib/issue-781-document-usability.ts @@ -6,13 +6,34 @@ const LINKIFIED_ATTR = 'data-kun-issue781-linkified' const FILE_PATH_ATTR = 'data-kun-issue781-file-path' const FILE_LINE_ATTR = 'data-kun-issue781-file-line' const FILE_COLUMN_ATTR = 'data-kun-issue781-file-column' +const ENHANCED_ATTR = 'data-kun-issue781-enhanced' const STYLE_ID = 'kun-issue-781-document-usability-style' +const PINNED_TABS_KEY = 'kun.issue781.pinnedPreviewTabs' +const SCROLL_POSITIONS_KEY = 'kun.issue781.previewScrollPositions' let installed = false let observer: MutationObserver | null = null let scanTimer: number | null = null let lastPreviewTarget: WorkspaceFileTarget | null = null let lastUserCloseAt = 0 +let menuEl: HTMLDivElement | null = null + +function readJson(key: string, fallback: T): T { + try { + const raw = window.localStorage.getItem(key) + return raw ? JSON.parse(raw) as T : fallback + } catch { + return fallback + } +} + +function writeJson(key: string, value: T): void { + try { + window.localStorage.setItem(key, JSON.stringify(value)) + } catch { + // Ignore storage quota / private-mode errors. The feature remains usable in-memory. + } +} function injectStyle(): void { if (document.getElementById(STYLE_ID)) return @@ -38,6 +59,90 @@ function injectStyle(): void { background: color-mix(in srgb, var(--ds-accent) 17%, transparent); text-decoration-color: var(--ds-accent); } + .ds-code-sidebar-tab.kun-issue781-pinned::before { + content: '📌'; + margin-right: 2px; + font-size: 10px; + opacity: 0.78; + } + .kun-issue781-menu { + position: fixed; + z-index: 9999; + min-width: 172px; + border: 1px solid var(--ds-border); + border-radius: 10px; + background: var(--ds-card); + box-shadow: 0 18px 50px rgba(15, 23, 42, 0.22); + padding: 5px; + } + .kun-issue781-menu button { + display: block; + width: 100%; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--ds-ink); + cursor: pointer; + font: inherit; + font-size: 12px; + padding: 7px 9px; + text-align: left; + } + .kun-issue781-menu button:hover { background: var(--ds-hover); } + .kun-issue781-reader-overlay { + position: fixed; + inset: 18px; + z-index: 9998; + display: flex; + min-height: 0; + flex-direction: column; + border: 1px solid var(--ds-border); + border-radius: 18px; + background: var(--ds-main); + box-shadow: 0 28px 80px rgba(15, 23, 42, 0.36); + overflow: hidden; + } + .kun-issue781-reader-toolbar { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: space-between; + gap: 12px; + border-bottom: 1px solid var(--ds-border-muted); + background: var(--ds-sidebar); + padding: 10px 14px; + font-size: 13px; + font-weight: 650; + color: var(--ds-ink); + } + .kun-issue781-reader-toolbar button, + .kun-issue781-expand-button { + border: 1px solid var(--ds-border-muted); + border-radius: 8px; + background: var(--ds-card); + color: var(--ds-muted); + cursor: pointer; + font: inherit; + font-size: 12px; + padding: 5px 8px; + } + .kun-issue781-reader-toolbar button:hover, + .kun-issue781-expand-button:hover { + background: var(--ds-hover); + color: var(--ds-ink); + } + .kun-issue781-reader-body { + min-height: 0; + flex: 1 1 auto; + overflow: auto; + padding: 18px min(7vw, 72px); + } + .kun-issue781-reader-body .ds-code-sidebar { + height: auto; + min-height: 100%; + border-left: 0; + } + .kun-issue781-reader-body .ds-code-sidebar-topbar { display: none; } ` document.head.appendChild(style) } @@ -60,6 +165,33 @@ function targetFromDataset(element: HTMLElement): WorkspaceFileTarget | null { } } +function tabKey(tab: Element | null): string { + return tab instanceof HTMLElement ? (tab.title || tab.textContent || '').trim() : '' +} + +function activeTabKey(): string { + return tabKey(document.querySelector('.ds-code-sidebar-tab.is-active')) +} + +function pinnedTabs(): string[] { + return readJson(PINNED_TABS_KEY, []) +} + +function setPinnedTabs(next: string[]): void { + writeJson(PINNED_TABS_KEY, Array.from(new Set(next.filter(Boolean)))) +} + +function scrollPositions(): Record { + return readJson>(SCROLL_POSITIONS_KEY, {}) +} + +function setScrollPosition(key: string, value: number): void { + if (!key) return + const next = scrollPositions() + next[key] = value + writeJson(SCROLL_POSITIONS_KEY, next) +} + function recordPreviewTarget(target: WorkspaceFileTarget): void { lastPreviewTarget = target } @@ -116,11 +248,145 @@ function scanRenderedOutput(): void { for (const container of containers) linkifyContainer(container) } +function applyPinnedClasses(): void { + const pinned = new Set(pinnedTabs()) + document.querySelectorAll('.ds-code-sidebar-tab').forEach((tab) => { + tab.classList.toggle('kun-issue781-pinned', pinned.has(tabKey(tab))) + }) +} + +function closeIssue781Menu(): void { + menuEl?.remove() + menuEl = null +} + +function showTabMenu(tab: HTMLElement, x: number, y: number): void { + closeIssue781Menu() + const key = tabKey(tab) + const pinned = new Set(pinnedTabs()) + const menu = document.createElement('div') + menu.className = 'kun-issue781-menu' + menu.style.left = `${x}px` + menu.style.top = `${y}px` + menu.innerHTML = ` + + + ` + menu.addEventListener('click', (event) => { + const actionTarget = event.target + if (!(actionTarget instanceof HTMLElement)) return + const action = actionTarget.getAttribute('data-action') + if (action === 'pin') { + if (pinned.has(key)) pinned.delete(key) + else pinned.add(key) + setPinnedTabs([...pinned]) + applyPinnedClasses() + } else if (action === 'close-others') { + const pinnedNow = new Set(pinnedTabs()) + document.querySelectorAll('.ds-code-sidebar-tab').forEach((item) => { + const itemKey = tabKey(item) + if (item === tab || pinnedNow.has(itemKey)) return + const close = item.querySelector('.ds-code-sidebar-tab-close') + if (close instanceof HTMLButtonElement) close.click() + }) + } + closeIssue781Menu() + }) + document.body.appendChild(menu) + menuEl = menu +} + +function enhancePreviewTabs(): void { + applyPinnedClasses() + const tabs = document.querySelector('.ds-code-sidebar-tabs') + if (!(tabs instanceof HTMLElement) || tabs.getAttribute(ENHANCED_ATTR) === 'tabs') return + tabs.setAttribute(ENHANCED_ATTR, 'tabs') + tabs.addEventListener('wheel', (event) => { + const tabList = Array.from(tabs.querySelectorAll('.ds-code-sidebar-tab')) as HTMLElement[] + if (tabList.length < 2) return + event.preventDefault() + const activeIndex = Math.max(0, tabList.findIndex((tab) => tab.classList.contains('is-active'))) + const nextIndex = (activeIndex + (event.deltaY > 0 ? 1 : -1) + tabList.length) % tabList.length + tabList[nextIndex]?.click() + }, { passive: false }) + tabs.addEventListener('contextmenu', (event) => { + const target = event.target + if (!(target instanceof HTMLElement)) return + const tab = target.closest('.ds-code-sidebar-tab') + if (!(tab instanceof HTMLElement)) return + event.preventDefault() + showTabMenu(tab, event.clientX, event.clientY) + }) +} + +function enhanceScrollMemory(): void { + const scrollers = document.querySelectorAll('.ds-file-preview-scroll, .ds-file-preview-markdown') + scrollers.forEach((element) => { + if (!(element instanceof HTMLElement)) return + if (element.getAttribute(ENHANCED_ATTR) !== 'scroll') { + element.setAttribute(ENHANCED_ATTR, 'scroll') + element.addEventListener('scroll', () => setScrollPosition(activeTabKey(), element.scrollTop), { passive: true }) + } + const key = activeTabKey() + const stored = scrollPositions()[key] + if (key && typeof stored === 'number' && Math.abs(element.scrollTop - stored) > 4) { + window.requestAnimationFrame(() => { + element.scrollTop = stored + }) + } + }) +} + +function openReadingOverlay(): void { + const sidebar = document.querySelector('.ds-code-sidebar') + if (!(sidebar instanceof HTMLElement)) return + document.querySelector('.kun-issue781-reader-overlay')?.remove() + const active = activeTabKey() + const overlay = document.createElement('div') + overlay.className = 'kun-issue781-reader-overlay' + overlay.innerHTML = ` +
+ ${active || 'Document preview'} + +
+
+ ` + overlay.querySelector('[data-close="1"]')?.addEventListener('click', () => overlay.remove()) + overlay.addEventListener('keydown', (event) => { + if (event.key === 'Escape') overlay.remove() + }) + const body = overlay.querySelector('.kun-issue781-reader-body') + body?.appendChild(sidebar.cloneNode(true)) + document.body.appendChild(overlay) + overlay.tabIndex = -1 + overlay.focus() +} + +function enhanceReadingButton(): void { + const actions = document.querySelector('.ds-code-sidebar-actions') + if (!(actions instanceof HTMLElement) || actions.querySelector('.kun-issue781-expand-button')) return + const button = document.createElement('button') + button.type = 'button' + button.className = 'kun-issue781-expand-button' + button.title = '放大阅读' + button.ariaLabel = '放大阅读' + button.textContent = '阅读' + button.addEventListener('click', (event) => { + event.preventDefault() + event.stopPropagation() + openReadingOverlay() + }) + actions.insertBefore(button, actions.firstChild) +} + function scheduleScan(): void { if (scanTimer !== null) return scanTimer = window.setTimeout(() => { scanTimer = null scanRenderedOutput() + enhancePreviewTabs() + enhanceScrollMemory() + enhanceReadingButton() }, 120) } @@ -165,7 +431,13 @@ export function installIssue781DocumentUsability(): void { installed = true injectStyle() scanRenderedOutput() + enhancePreviewTabs() + enhanceScrollMemory() + enhanceReadingButton() document.addEventListener('click', onDocumentClick, true) + document.addEventListener('pointerdown', (event) => { + if (menuEl && event.target instanceof Node && !menuEl.contains(event.target)) closeIssue781Menu() + }, true) window.addEventListener(WORKSPACE_FILE_PREVIEW_EVENT, onPreviewEvent) observer = new MutationObserver(() => { scheduleScan() From bc2fbbb5b42517d84c319afe3a87dfcc49b9b054 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?luoye=E3=82=B9=E3=82=AD?= <100058663+luoye520ww@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:37:38 +0800 Subject: [PATCH 07/17] feat(files): add recent file shortcuts and drag references --- .../src/lib/issue-781-document-usability.ts | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) diff --git a/src/renderer/src/lib/issue-781-document-usability.ts b/src/renderer/src/lib/issue-781-document-usability.ts index 015514d2f..2d369f481 100644 --- a/src/renderer/src/lib/issue-781-document-usability.ts +++ b/src/renderer/src/lib/issue-781-document-usability.ts @@ -10,6 +10,9 @@ const ENHANCED_ATTR = 'data-kun-issue781-enhanced' const STYLE_ID = 'kun-issue-781-document-usability-style' const PINNED_TABS_KEY = 'kun.issue781.pinnedPreviewTabs' const SCROLL_POSITIONS_KEY = 'kun.issue781.previewScrollPositions' +const RECENT_FILES_KEY = 'kun.issue781.recentWorkspaceFiles' +const FILE_TREE_SORT_KEY = 'kun.issue781.fileTreeSortMode' +const RECENT_LIMIT = 16 let installed = false let observer: MutationObserver | null = null @@ -143,6 +146,57 @@ function injectStyle(): void { border-left: 0; } .kun-issue781-reader-body .ds-code-sidebar-topbar { display: none; } + .kun-issue781-recent-files { + flex: 0 0 auto; + border-bottom: 1px solid var(--ds-border-muted); + padding: 8px 8px 7px; + } + .kun-issue781-recent-files-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; + font-size: 11px; + font-weight: 700; + color: var(--ds-faint); + letter-spacing: .02em; + } + .kun-issue781-sort-button, + .kun-issue781-recent-file { + border: 1px solid var(--ds-border-muted); + border-radius: 8px; + background: var(--ds-card); + color: var(--ds-muted); + cursor: pointer; + font: inherit; + font-size: 11.5px; + } + .kun-issue781-sort-button { padding: 3px 6px; } + .kun-issue781-recent-list { + display: flex; + flex-direction: column; + gap: 4px; + } + .kun-issue781-recent-file { + display: flex; + min-width: 0; + align-items: center; + justify-content: flex-start; + padding: 5px 7px; + text-align: left; + } + .kun-issue781-sort-button:hover, + .kun-issue781-recent-file:hover { + background: var(--ds-hover); + color: var(--ds-ink); + } + .kun-issue781-recent-file span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } ` document.head.appendChild(style) } @@ -173,6 +227,10 @@ function activeTabKey(): string { return tabKey(document.querySelector('.ds-code-sidebar-tab.is-active')) } +function displayName(path: string): string { + return path.split(/[\\/]/).filter(Boolean).pop() || path +} + function pinnedTabs(): string[] { return readJson(PINNED_TABS_KEY, []) } @@ -181,6 +239,19 @@ function setPinnedTabs(next: string[]): void { writeJson(PINNED_TABS_KEY, Array.from(new Set(next.filter(Boolean)))) } +function recentFiles(): WorkspaceFileTarget[] { + return readJson(RECENT_FILES_KEY, []) +} + +function rememberRecentFile(target: WorkspaceFileTarget): void { + const normalizedPath = target.path.replaceAll('\\', '/') + const next = [ + { ...target, path: normalizedPath }, + ...recentFiles().filter((item) => item.path.replaceAll('\\', '/') !== normalizedPath) + ].slice(0, RECENT_LIMIT) + writeJson(RECENT_FILES_KEY, next) +} + function scrollPositions(): Record { return readJson>(SCROLL_POSITIONS_KEY, {}) } @@ -194,6 +265,7 @@ function setScrollPosition(key: string, value: number): void { function recordPreviewTarget(target: WorkspaceFileTarget): void { lastPreviewTarget = target + rememberRecentFile(target) } function linkifyTextNode(node: Text): void { @@ -379,6 +451,102 @@ function enhanceReadingButton(): void { actions.insertBefore(button, actions.firstChild) } +function likelyWorkspacePath(value: string): boolean { + const trimmed = value.trim() + if (!trimmed || trimmed.includes(' is not a supported text preview')) return false + return /[\\/]/.test(trimmed) || findFileReferences(trimmed).length > 0 +} + +function fileTreeRoots(): HTMLElement[] { + return Array.from(document.querySelectorAll('div.ds-no-drag.min-h-0')) + .filter((root): root is HTMLElement => { + if (!(root instanceof HTMLElement)) return false + if (root.closest('.ds-code-sidebar')) return false + return Boolean(root.querySelector('div[class*="overflow-y-auto"] [title]')) + }) +} + +function applyFileTreeSort(root: HTMLElement): void { + const mode = window.localStorage.getItem(FILE_TREE_SORT_KEY) || 'name' + const recentRank = new Map(recentFiles().map((file, index) => [file.path.replaceAll('\\', '/').toLowerCase(), index])) + const rows = Array.from(root.querySelectorAll('div[class*="overflow-y-auto"] [title]')) as HTMLElement[] + rows.forEach((row, index) => { + const title = row.title.replaceAll('\\', '/').toLowerCase() + const rank = recentRank.get(title) + row.style.order = mode === 'recent' + ? String(rank === undefined ? 10000 + index : rank) + : '' + }) +} + +function toggleFileTreeSort(): void { + const current = window.localStorage.getItem(FILE_TREE_SORT_KEY) || 'name' + window.localStorage.setItem(FILE_TREE_SORT_KEY, current === 'recent' ? 'name' : 'recent') + enhanceFileTreeUtilities() +} + +function addRecentFilesPanel(root: HTMLElement): void { + const scroll = root.querySelector('div[class*="overflow-y-auto"]') + if (!(scroll instanceof HTMLElement)) return + let panel = root.querySelector('.kun-issue781-recent-files') as HTMLDivElement | null + if (!panel) { + panel = document.createElement('div') + panel.className = 'kun-issue781-recent-files' + scroll.parentElement?.insertBefore(panel, scroll) + } + const recent = recentFiles().slice(0, 8) + const mode = window.localStorage.getItem(FILE_TREE_SORT_KEY) || 'name' + panel.innerHTML = ` +
+ 近期文件 + +
+
+ ` + const sortButton = panel.querySelector('.kun-issue781-sort-button') + sortButton?.addEventListener('click', toggleFileTreeSort) + const list = panel.querySelector('.kun-issue781-recent-list') + recent.forEach((target) => { + const item = document.createElement('button') + item.type = 'button' + item.className = 'kun-issue781-recent-file' + item.title = target.path + item.draggable = true + item.innerHTML = `${displayName(target.path)}` + item.addEventListener('click', () => previewWorkspaceFile(target)) + item.addEventListener('dragstart', (event) => { + event.dataTransfer?.setData('text/plain', `@${target.path} `) + event.dataTransfer?.setData('application/x-kun-file-reference', JSON.stringify(target)) + event.dataTransfer?.setDragImage(item, 10, 10) + }) + list?.appendChild(item) + }) +} + +function enhanceFileTreeDrag(root: HTMLElement): void { + const rows = Array.from(root.querySelectorAll('div[class*="overflow-y-auto"] [title]')) as HTMLElement[] + rows.forEach((row) => { + if (row.getAttribute(ENHANCED_ATTR) === 'drag') return + const title = row.title.trim() + if (!likelyWorkspacePath(title)) return + row.setAttribute(ENHANCED_ATTR, 'drag') + row.draggable = true + row.addEventListener('dragstart', (event) => { + const path = title.replaceAll('\\', '/') + event.dataTransfer?.setData('text/plain', `@${path} `) + event.dataTransfer?.setData('application/x-kun-file-reference', JSON.stringify({ path })) + }) + }) +} + +function enhanceFileTreeUtilities(): void { + for (const root of fileTreeRoots()) { + addRecentFilesPanel(root) + applyFileTreeSort(root) + enhanceFileTreeDrag(root) + } +} + function scheduleScan(): void { if (scanTimer !== null) return scanTimer = window.setTimeout(() => { @@ -387,6 +555,7 @@ function scheduleScan(): void { enhancePreviewTabs() enhanceScrollMemory() enhanceReadingButton() + enhanceFileTreeUtilities() }, 120) } @@ -434,6 +603,7 @@ export function installIssue781DocumentUsability(): void { enhancePreviewTabs() enhanceScrollMemory() enhanceReadingButton() + enhanceFileTreeUtilities() document.addEventListener('click', onDocumentClick, true) document.addEventListener('pointerdown', (event) => { if (menuEl && event.target instanceof Node && !menuEl.contains(event.target)) closeIssue781Menu() From c7eca7a1cf3edecdb6517e214233ccf0ea7aa9dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?luoye=E3=82=B9=E3=82=AD?= <100058663+luoye520ww@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:23:08 +0800 Subject: [PATCH 08/17] fix(files): remove cross-workspace preview restore and live-clone reader --- .../src/lib/issue-781-document-usability.ts | 331 +++--------------- 1 file changed, 40 insertions(+), 291 deletions(-) diff --git a/src/renderer/src/lib/issue-781-document-usability.ts b/src/renderer/src/lib/issue-781-document-usability.ts index 2d369f481..3dc94bf8b 100644 --- a/src/renderer/src/lib/issue-781-document-usability.ts +++ b/src/renderer/src/lib/issue-781-document-usability.ts @@ -1,6 +1,6 @@ import type { WorkspaceFileTarget } from '@shared/workspace-file' import { findFileReferences } from './file-references' -import { previewWorkspaceFile, WORKSPACE_FILE_PREVIEW_EVENT, type WorkspaceFilePreviewDetail } from './workspace-file-preview' +import { previewWorkspaceFile } from './workspace-file-preview' const LINKIFIED_ATTR = 'data-kun-issue781-linkified' const FILE_PATH_ATTR = 'data-kun-issue781-file-path' @@ -10,15 +10,10 @@ const ENHANCED_ATTR = 'data-kun-issue781-enhanced' const STYLE_ID = 'kun-issue-781-document-usability-style' const PINNED_TABS_KEY = 'kun.issue781.pinnedPreviewTabs' const SCROLL_POSITIONS_KEY = 'kun.issue781.previewScrollPositions' -const RECENT_FILES_KEY = 'kun.issue781.recentWorkspaceFiles' -const FILE_TREE_SORT_KEY = 'kun.issue781.fileTreeSortMode' -const RECENT_LIMIT = 16 let installed = false let observer: MutationObserver | null = null let scanTimer: number | null = null -let lastPreviewTarget: WorkspaceFileTarget | null = null -let lastUserCloseAt = 0 let menuEl: HTMLDivElement | null = null function readJson(key: string, fallback: T): T { @@ -92,111 +87,18 @@ function injectStyle(): void { text-align: left; } .kun-issue781-menu button:hover { background: var(--ds-hover); } - .kun-issue781-reader-overlay { + .ds-code-sidebar.kun-issue781-reader-mode { position: fixed; inset: 18px; z-index: 9998; - display: flex; - min-height: 0; - flex-direction: column; + width: auto !important; + min-width: 0 !important; border: 1px solid var(--ds-border); border-radius: 18px; background: var(--ds-main); box-shadow: 0 28px 80px rgba(15, 23, 42, 0.36); overflow: hidden; } - .kun-issue781-reader-toolbar { - display: flex; - flex: 0 0 auto; - align-items: center; - justify-content: space-between; - gap: 12px; - border-bottom: 1px solid var(--ds-border-muted); - background: var(--ds-sidebar); - padding: 10px 14px; - font-size: 13px; - font-weight: 650; - color: var(--ds-ink); - } - .kun-issue781-reader-toolbar button, - .kun-issue781-expand-button { - border: 1px solid var(--ds-border-muted); - border-radius: 8px; - background: var(--ds-card); - color: var(--ds-muted); - cursor: pointer; - font: inherit; - font-size: 12px; - padding: 5px 8px; - } - .kun-issue781-reader-toolbar button:hover, - .kun-issue781-expand-button:hover { - background: var(--ds-hover); - color: var(--ds-ink); - } - .kun-issue781-reader-body { - min-height: 0; - flex: 1 1 auto; - overflow: auto; - padding: 18px min(7vw, 72px); - } - .kun-issue781-reader-body .ds-code-sidebar { - height: auto; - min-height: 100%; - border-left: 0; - } - .kun-issue781-reader-body .ds-code-sidebar-topbar { display: none; } - .kun-issue781-recent-files { - flex: 0 0 auto; - border-bottom: 1px solid var(--ds-border-muted); - padding: 8px 8px 7px; - } - .kun-issue781-recent-files-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - margin-bottom: 6px; - font-size: 11px; - font-weight: 700; - color: var(--ds-faint); - letter-spacing: .02em; - } - .kun-issue781-sort-button, - .kun-issue781-recent-file { - border: 1px solid var(--ds-border-muted); - border-radius: 8px; - background: var(--ds-card); - color: var(--ds-muted); - cursor: pointer; - font: inherit; - font-size: 11.5px; - } - .kun-issue781-sort-button { padding: 3px 6px; } - .kun-issue781-recent-list { - display: flex; - flex-direction: column; - gap: 4px; - } - .kun-issue781-recent-file { - display: flex; - min-width: 0; - align-items: center; - justify-content: flex-start; - padding: 5px 7px; - text-align: left; - } - .kun-issue781-sort-button:hover, - .kun-issue781-recent-file:hover { - background: var(--ds-hover); - color: var(--ds-ink); - } - .kun-issue781-recent-file span { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } ` document.head.appendChild(style) } @@ -227,10 +129,6 @@ function activeTabKey(): string { return tabKey(document.querySelector('.ds-code-sidebar-tab.is-active')) } -function displayName(path: string): string { - return path.split(/[\\/]/).filter(Boolean).pop() || path -} - function pinnedTabs(): string[] { return readJson(PINNED_TABS_KEY, []) } @@ -239,19 +137,6 @@ function setPinnedTabs(next: string[]): void { writeJson(PINNED_TABS_KEY, Array.from(new Set(next.filter(Boolean)))) } -function recentFiles(): WorkspaceFileTarget[] { - return readJson(RECENT_FILES_KEY, []) -} - -function rememberRecentFile(target: WorkspaceFileTarget): void { - const normalizedPath = target.path.replaceAll('\\', '/') - const next = [ - { ...target, path: normalizedPath }, - ...recentFiles().filter((item) => item.path.replaceAll('\\', '/') !== normalizedPath) - ].slice(0, RECENT_LIMIT) - writeJson(RECENT_FILES_KEY, next) -} - function scrollPositions(): Record { return readJson>(SCROLL_POSITIONS_KEY, {}) } @@ -263,11 +148,6 @@ function setScrollPosition(key: string, value: number): void { writeJson(SCROLL_POSITIONS_KEY, next) } -function recordPreviewTarget(target: WorkspaceFileTarget): void { - lastPreviewTarget = target - rememberRecentFile(target) -} - function linkifyTextNode(node: Text): void { if (isBlockedTextNode(node)) return const text = node.nodeValue ?? '' @@ -340,30 +220,30 @@ function showTabMenu(tab: HTMLElement, x: number, y: number): void { menu.className = 'kun-issue781-menu' menu.style.left = `${x}px` menu.style.top = `${y}px` - menu.innerHTML = ` - - - ` - menu.addEventListener('click', (event) => { - const actionTarget = event.target - if (!(actionTarget instanceof HTMLElement)) return - const action = actionTarget.getAttribute('data-action') - if (action === 'pin') { - if (pinned.has(key)) pinned.delete(key) - else pinned.add(key) - setPinnedTabs([...pinned]) - applyPinnedClasses() - } else if (action === 'close-others') { - const pinnedNow = new Set(pinnedTabs()) - document.querySelectorAll('.ds-code-sidebar-tab').forEach((item) => { - const itemKey = tabKey(item) - if (item === tab || pinnedNow.has(itemKey)) return - const close = item.querySelector('.ds-code-sidebar-tab-close') - if (close instanceof HTMLButtonElement) close.click() - }) - } + const pinButton = document.createElement('button') + pinButton.type = 'button' + pinButton.textContent = pinned.has(key) ? '取消固定标签' : '固定标签' + const closeOthersButton = document.createElement('button') + closeOthersButton.type = 'button' + closeOthersButton.textContent = '关闭其他标签页' + pinButton.addEventListener('click', () => { + if (pinned.has(key)) pinned.delete(key) + else pinned.add(key) + setPinnedTabs([...pinned]) + applyPinnedClasses() closeIssue781Menu() }) + closeOthersButton.addEventListener('click', () => { + const pinnedNow = new Set(pinnedTabs()) + document.querySelectorAll('.ds-code-sidebar-tab').forEach((item) => { + const itemKey = tabKey(item) + if (item === tab || pinnedNow.has(itemKey)) return + const close = item.querySelector('.ds-code-sidebar-tab-close') + if (close instanceof HTMLButtonElement) close.click() + }) + closeIssue781Menu() + }) + menu.append(pinButton, closeOthersButton) document.body.appendChild(menu) menuEl = menu } @@ -409,29 +289,16 @@ function enhanceScrollMemory(): void { }) } -function openReadingOverlay(): void { +function setReadingMode(enabled: boolean): void { const sidebar = document.querySelector('.ds-code-sidebar') if (!(sidebar instanceof HTMLElement)) return - document.querySelector('.kun-issue781-reader-overlay')?.remove() - const active = activeTabKey() - const overlay = document.createElement('div') - overlay.className = 'kun-issue781-reader-overlay' - overlay.innerHTML = ` -
- ${active || 'Document preview'} - -
-
- ` - overlay.querySelector('[data-close="1"]')?.addEventListener('click', () => overlay.remove()) - overlay.addEventListener('keydown', (event) => { - if (event.key === 'Escape') overlay.remove() - }) - const body = overlay.querySelector('.kun-issue781-reader-body') - body?.appendChild(sidebar.cloneNode(true)) - document.body.appendChild(overlay) - overlay.tabIndex = -1 - overlay.focus() + sidebar.classList.toggle('kun-issue781-reader-mode', enabled) + const button = sidebar.querySelector('.kun-issue781-expand-button') + if (button instanceof HTMLButtonElement) { + button.textContent = enabled ? '退出阅读' : '阅读' + button.title = enabled ? '退出阅读' : '放大阅读' + button.ariaLabel = button.title + } } function enhanceReadingButton(): void { @@ -439,114 +306,19 @@ function enhanceReadingButton(): void { if (!(actions instanceof HTMLElement) || actions.querySelector('.kun-issue781-expand-button')) return const button = document.createElement('button') button.type = 'button' - button.className = 'kun-issue781-expand-button' + button.className = 'kun-issue781-expand-button ds-code-sidebar-icon-button' button.title = '放大阅读' button.ariaLabel = '放大阅读' button.textContent = '阅读' button.addEventListener('click', (event) => { event.preventDefault() event.stopPropagation() - openReadingOverlay() + const sidebar = document.querySelector('.ds-code-sidebar') + setReadingMode(!(sidebar instanceof HTMLElement && sidebar.classList.contains('kun-issue781-reader-mode'))) }) actions.insertBefore(button, actions.firstChild) } -function likelyWorkspacePath(value: string): boolean { - const trimmed = value.trim() - if (!trimmed || trimmed.includes(' is not a supported text preview')) return false - return /[\\/]/.test(trimmed) || findFileReferences(trimmed).length > 0 -} - -function fileTreeRoots(): HTMLElement[] { - return Array.from(document.querySelectorAll('div.ds-no-drag.min-h-0')) - .filter((root): root is HTMLElement => { - if (!(root instanceof HTMLElement)) return false - if (root.closest('.ds-code-sidebar')) return false - return Boolean(root.querySelector('div[class*="overflow-y-auto"] [title]')) - }) -} - -function applyFileTreeSort(root: HTMLElement): void { - const mode = window.localStorage.getItem(FILE_TREE_SORT_KEY) || 'name' - const recentRank = new Map(recentFiles().map((file, index) => [file.path.replaceAll('\\', '/').toLowerCase(), index])) - const rows = Array.from(root.querySelectorAll('div[class*="overflow-y-auto"] [title]')) as HTMLElement[] - rows.forEach((row, index) => { - const title = row.title.replaceAll('\\', '/').toLowerCase() - const rank = recentRank.get(title) - row.style.order = mode === 'recent' - ? String(rank === undefined ? 10000 + index : rank) - : '' - }) -} - -function toggleFileTreeSort(): void { - const current = window.localStorage.getItem(FILE_TREE_SORT_KEY) || 'name' - window.localStorage.setItem(FILE_TREE_SORT_KEY, current === 'recent' ? 'name' : 'recent') - enhanceFileTreeUtilities() -} - -function addRecentFilesPanel(root: HTMLElement): void { - const scroll = root.querySelector('div[class*="overflow-y-auto"]') - if (!(scroll instanceof HTMLElement)) return - let panel = root.querySelector('.kun-issue781-recent-files') as HTMLDivElement | null - if (!panel) { - panel = document.createElement('div') - panel.className = 'kun-issue781-recent-files' - scroll.parentElement?.insertBefore(panel, scroll) - } - const recent = recentFiles().slice(0, 8) - const mode = window.localStorage.getItem(FILE_TREE_SORT_KEY) || 'name' - panel.innerHTML = ` -
- 近期文件 - -
-
- ` - const sortButton = panel.querySelector('.kun-issue781-sort-button') - sortButton?.addEventListener('click', toggleFileTreeSort) - const list = panel.querySelector('.kun-issue781-recent-list') - recent.forEach((target) => { - const item = document.createElement('button') - item.type = 'button' - item.className = 'kun-issue781-recent-file' - item.title = target.path - item.draggable = true - item.innerHTML = `${displayName(target.path)}` - item.addEventListener('click', () => previewWorkspaceFile(target)) - item.addEventListener('dragstart', (event) => { - event.dataTransfer?.setData('text/plain', `@${target.path} `) - event.dataTransfer?.setData('application/x-kun-file-reference', JSON.stringify(target)) - event.dataTransfer?.setDragImage(item, 10, 10) - }) - list?.appendChild(item) - }) -} - -function enhanceFileTreeDrag(root: HTMLElement): void { - const rows = Array.from(root.querySelectorAll('div[class*="overflow-y-auto"] [title]')) as HTMLElement[] - rows.forEach((row) => { - if (row.getAttribute(ENHANCED_ATTR) === 'drag') return - const title = row.title.trim() - if (!likelyWorkspacePath(title)) return - row.setAttribute(ENHANCED_ATTR, 'drag') - row.draggable = true - row.addEventListener('dragstart', (event) => { - const path = title.replaceAll('\\', '/') - event.dataTransfer?.setData('text/plain', `@${path} `) - event.dataTransfer?.setData('application/x-kun-file-reference', JSON.stringify({ path })) - }) - }) -} - -function enhanceFileTreeUtilities(): void { - for (const root of fileTreeRoots()) { - addRecentFilesPanel(root) - applyFileTreeSort(root) - enhanceFileTreeDrag(root) - } -} - function scheduleScan(): void { if (scanTimer !== null) return scanTimer = window.setTimeout(() => { @@ -555,20 +327,9 @@ function scheduleScan(): void { enhancePreviewTabs() enhanceScrollMemory() enhanceReadingButton() - enhanceFileTreeUtilities() }, 120) } -function restorePreviewIfThreadSwitchClosedIt(): void { - if (!lastPreviewTarget) return - if (Date.now() - lastUserCloseAt < 1200) return - if (document.querySelector('.ds-code-sidebar')) return - window.setTimeout(() => { - if (!lastPreviewTarget || document.querySelector('.ds-code-sidebar')) return - previewWorkspaceFile(lastPreviewTarget) - }, 180) -} - function onDocumentClick(event: MouseEvent): void { const target = event.target if (!(target instanceof HTMLElement)) return @@ -579,20 +340,8 @@ function onDocumentClick(event: MouseEvent): void { if (!fileTarget) return event.preventDefault() event.stopPropagation() - recordPreviewTarget(fileTarget) previewWorkspaceFile(fileTarget) - return } - - if (target.closest('.ds-code-sidebar-actions button:last-child')) { - lastUserCloseAt = Date.now() - } -} - -function onPreviewEvent(event: Event): void { - const detail = (event as CustomEvent).detail - if (!detail?.path) return - recordPreviewTarget(detail) } export function installIssue781DocumentUsability(): void { @@ -603,15 +352,15 @@ export function installIssue781DocumentUsability(): void { enhancePreviewTabs() enhanceScrollMemory() enhanceReadingButton() - enhanceFileTreeUtilities() document.addEventListener('click', onDocumentClick, true) + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape') setReadingMode(false) + }) document.addEventListener('pointerdown', (event) => { if (menuEl && event.target instanceof Node && !menuEl.contains(event.target)) closeIssue781Menu() }, true) - window.addEventListener(WORKSPACE_FILE_PREVIEW_EVENT, onPreviewEvent) observer = new MutationObserver(() => { scheduleScan() - restorePreviewIfThreadSwitchClosedIt() }) observer.observe(document.body, { childList: true, subtree: true }) } From f5844f85757fa006a462aa569a18e3765075c007 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?luoye=E3=82=B9=E3=82=AD?= <100058663+luoye520ww@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:24:49 +0800 Subject: [PATCH 09/17] fix(files): expose workspace entry metadata for real sorting --- src/shared/workspace-file.ts | 49 ++---------------------------------- 1 file changed, 2 insertions(+), 47 deletions(-) diff --git a/src/shared/workspace-file.ts b/src/shared/workspace-file.ts index ee178b5bf..24699ca39 100644 --- a/src/shared/workspace-file.ts +++ b/src/shared/workspace-file.ts @@ -10,6 +10,8 @@ export type WorkspaceEntry = { path: string type: 'file' | 'directory' ext: string + mtimeMs?: number + size?: number } export type WorkspaceDirectoryTarget = { @@ -219,7 +221,6 @@ export type WorkspaceFileCreateResult = | { ok: true path: string - createdAt: string } | { ok: false; message: string } @@ -227,7 +228,6 @@ export type WorkspaceDirectoryCreateResult = | { ok: true path: string - createdAt: string } | { ok: false; message: string } @@ -235,56 +235,11 @@ export type WorkspaceEntryRenameResult = | { ok: true path: string - previousPath: string - renamedAt: string } | { ok: false; message: string } export type WorkspaceEntryDeleteResult = | { ok: true - path: string - deletedAt: string - } - | { ok: false; message: string } - -export type WorkspaceFileWatchResult = - | { - ok: true - watchId: string - path: string - content: string - size: number - truncated: boolean - startedAt: string - } - | { ok: false; message: string } - -export type WorkspaceClipboardImageSaveResult = - | { - ok: true - path: string - markdownPath: string - createdAt: string } | { ok: false; message: string } - -export type WorkspaceFileChangePayload = - | { - ok: true - watchId: string - workspaceRoot: string - path: string - content: string - size: number - truncated: boolean - changedAt: string - } - | { - ok: false - watchId: string - workspaceRoot: string - path: string - message: string - changedAt: string - } From 797206155b0b04c49ed6d6802d5600392940f3af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?luoye=E3=82=B9=E3=82=AD?= <100058663+luoye520ww@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:25:43 +0800 Subject: [PATCH 10/17] fix(files): sort file tree through React state --- .../src/components/chat/ChatFileTreePanel.tsx | 154 ++++++++++++++---- 1 file changed, 118 insertions(+), 36 deletions(-) diff --git a/src/renderer/src/components/chat/ChatFileTreePanel.tsx b/src/renderer/src/components/chat/ChatFileTreePanel.tsx index 6c15d4c7a..c21bf5515 100644 --- a/src/renderer/src/components/chat/ChatFileTreePanel.tsx +++ b/src/renderer/src/components/chat/ChatFileTreePanel.tsx @@ -17,12 +17,16 @@ import { useMemo, useRef, useState, + type DragEvent as ReactDragEvent, type MouseEvent as ReactMouseEvent, type ReactElement } from 'react' import type { TFunction } from 'i18next' import type { ComposerFileReference } from '../../lib/composer-file-references' -import { relativeWorkspacePath } from '../../lib/composer-file-references' +import { + formatComposerFileMentionToken, + relativeWorkspacePath +} from '../../lib/composer-file-references' import { isWorkspaceTextPreviewPath } from '../../lib/workspace-text-preview' import { SidebarIconButton, @@ -55,6 +59,8 @@ type ContextMenuState = { entry: WorkspaceEntry } | null +type FileTreeSortMode = 'name' | 'modified' + const ROOT_PATH = '' const IGNORED_DIRS = new Set(['.git', '.hg', '.svn', 'node_modules']) @@ -82,6 +88,23 @@ function entryReference(entry: WorkspaceEntry, workspaceRoot: string): ChatFileT } } +function compareEntriesByName(left: WorkspaceEntry, right: WorkspaceEntry): number { + if (left.type !== right.type) return left.type === 'directory' ? -1 : 1 + return left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: 'base' }) +} + +function compareEntriesByModified(left: WorkspaceEntry, right: WorkspaceEntry): number { + if (left.type !== right.type) return left.type === 'directory' ? -1 : 1 + const leftTime = left.mtimeMs ?? 0 + const rightTime = right.mtimeMs ?? 0 + if (leftTime !== rightTime) return rightTime - leftTime + return compareEntriesByName(left, right) +} + +function sortEntries(entries: WorkspaceEntry[], mode: FileTreeSortMode): WorkspaceEntry[] { + return [...entries].sort(mode === 'modified' ? compareEntriesByModified : compareEntriesByName) +} + export function isChatFileTreeIgnoredDirectory(name: string): boolean { return IGNORED_DIRS.has(name.toLowerCase()) } @@ -105,6 +128,7 @@ export function ChatFileTreePanel({ const [expanded, setExpanded] = useState>(() => new Set([ROOT_PATH])) const [directories, setDirectories] = useState>({}) const [contextMenu, setContextMenu] = useState(null) + const [sortMode, setSortMode] = useState('name') const menuRef = useRef(null) const root = workspaceRoot.trim() const rootName = useMemo(() => workspaceDisplayName(root), [root]) @@ -176,6 +200,20 @@ export function ChatFileTreePanel({ }, [contextMenu]) const selectedKey = useMemo(() => pathKey(selectedPath ?? ''), [selectedPath]) + const recentEntries = useMemo(() => { + const seen = new Set() + return Object.values(directories) + .flatMap((state) => state.entries) + .filter(isChatFileTreePreviewableEntry) + .filter((entry) => { + const key = pathKey(entry.path) + if (seen.has(key)) return false + seen.add(key) + return true + }) + .sort(compareEntriesByModified) + .slice(0, 8) + }, [directories]) if (!root) return null @@ -198,6 +236,14 @@ export function ChatFileTreePanel({ setContextMenu(null) } + const setEntryDragData = (event: ReactDragEvent, entry: WorkspaceEntry): void => { + const reference = entryReference(entry, root) + const token = formatComposerFileMentionToken(reference.relativePath, reference.type === 'directory') + event.dataTransfer.effectAllowed = 'copy' + event.dataTransfer.setData('text/plain', `${token} `) + event.dataTransfer.setData('application/x-kun-file-reference', JSON.stringify(reference)) + } + const copyEntryPath = async (entry: WorkspaceEntry, mode: 'absolute' | 'relative'): Promise => { if (!navigator?.clipboard?.writeText) return const value = mode === 'absolute' ? entry.path : relativeWorkspacePath(entry.path, root) @@ -260,7 +306,7 @@ export function ChatFileTreePanel({ : [] } - return state.entries + return sortEntries(state.entries, sortMode) .filter((entry) => entry.type !== 'directory' || !isChatFileTreeIgnoredDirectory(entry.name)) .flatMap((entry) => { const isDirectory = entry.type === 'directory' @@ -273,35 +319,40 @@ export function ChatFileTreePanel({ : : const row = ( - { - if (isDirectory) { - toggleDirectory(entry.path) - return - } - onPreviewFile(entry.path) - }} - onContextMenu={(event) => openContextMenu(event, entry)} - buttonClassName="items-center gap-1.5 py-1.5 pr-1.5 text-[12.5px]" - buttonStyle={{ paddingLeft: depth * 14 + 8 }} - trailing={ - isDirectory ? ( - entryExpanded ? ( - - ) : ( - - ) - ) : null - } + draggable + onDragStart={(event) => setEntryDragData(event, entry)} > - {icon} - - {entry.name} - - + { + if (isDirectory) { + toggleDirectory(entry.path) + return + } + onPreviewFile(entry.path) + }} + onContextMenu={(event) => openContextMenu(event, entry)} + buttonClassName="items-center gap-1.5 py-1.5 pr-1.5 text-[12.5px]" + buttonStyle={{ paddingLeft: depth * 14 + 8 }} + trailing={ + isDirectory ? ( + entryExpanded ? ( + + ) : ( + + ) + ) : null + } + > + {icon} + + {entry.name} + + + ) if (!isDirectory || !entryExpanded) return [row] return [row, ...renderDirectory(entry.path, depth + 1)] @@ -319,15 +370,46 @@ export function ChatFileTreePanel({ label={rootName || t('fileTreeTitle')} title={root} actions={ - - - + <> + setSortMode((mode) => mode === 'modified' ? 'name' : 'modified')} + > + {sortMode === 'modified' ? 'MT' : 'AZ'} + + + + + } /> + {recentEntries.length ? ( +
+
Recent files
+
+ {recentEntries.map((entry) => ( + + ))} +
+
+ ) : null}
{renderDirectory(ROOT_PATH, 0)}
From 75f36d1229c989912a5ebc662652a0e8d5889737 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?luoye=E3=82=B9=E3=82=AD?= <100058663+luoye520ww@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:26:45 +0800 Subject: [PATCH 11/17] fix(files): restore workspace file result types --- src/shared/workspace-file.ts | 47 ++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/shared/workspace-file.ts b/src/shared/workspace-file.ts index 24699ca39..cd05c4271 100644 --- a/src/shared/workspace-file.ts +++ b/src/shared/workspace-file.ts @@ -221,6 +221,7 @@ export type WorkspaceFileCreateResult = | { ok: true path: string + createdAt: string } | { ok: false; message: string } @@ -228,6 +229,7 @@ export type WorkspaceDirectoryCreateResult = | { ok: true path: string + createdAt: string } | { ok: false; message: string } @@ -235,11 +237,56 @@ export type WorkspaceEntryRenameResult = | { ok: true path: string + previousPath: string + renamedAt: string } | { ok: false; message: string } export type WorkspaceEntryDeleteResult = | { ok: true + path: string + deletedAt: string + } + | { ok: false; message: string } + +export type WorkspaceFileWatchResult = + | { + ok: true + watchId: string + path: string + content: string + size: number + truncated: boolean + startedAt: string + } + | { ok: false; message: string } + +export type WorkspaceClipboardImageSaveResult = + | { + ok: true + path: string + markdownPath: string + createdAt: string } | { ok: false; message: string } + +export type WorkspaceFileChangePayload = + | { + ok: true + watchId: string + workspaceRoot: string + path: string + content: string + size: number + truncated: boolean + changedAt: string + } + | { + ok: false + watchId: string + workspaceRoot: string + path: string + message: string + changedAt: string + } From 09f1c6dafded05c74fa4090d15db2a1e8cb2e5d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?luoye=E3=82=B9=E3=82=AD?= <100058663+luoye520ww@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:50:29 +0800 Subject: [PATCH 12/17] fix(files): export metadata-backed directory listing --- src/main/services/workspace-service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/services/workspace-service.ts b/src/main/services/workspace-service.ts index 1ab9d0925..caf18055d 100644 --- a/src/main/services/workspace-service.ts +++ b/src/main/services/workspace-service.ts @@ -1,3 +1,4 @@ export * from './workspace-paths' export * from './workspace-editors' export * from './workspace-files' +export { listWorkspaceDirectory } from './workspace-file-metadata' From 04ce7b7ae735d866516fe7970dfdbc36fd69680f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?luoye=E3=82=B9=E3=82=AD?= <100058663+luoye520ww@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:50:36 +0800 Subject: [PATCH 13/17] fix(files): attach mtime metadata to file tree entries --- src/main/services/workspace-file-metadata.ts | 31 ++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/main/services/workspace-file-metadata.ts diff --git a/src/main/services/workspace-file-metadata.ts b/src/main/services/workspace-file-metadata.ts new file mode 100644 index 000000000..d23dd35e6 --- /dev/null +++ b/src/main/services/workspace-file-metadata.ts @@ -0,0 +1,31 @@ +import { stat } from 'node:fs/promises' +import type { + WorkspaceDirectoryListResult, + WorkspaceDirectoryTarget, + WorkspaceEntry +} from '../../shared/workspace-file' +import { listWorkspaceDirectory as listWorkspaceDirectoryWithoutMetadata } from './workspace-files' + +async function withEntryMetadata(entry: WorkspaceEntry): Promise { + try { + const info = await stat(entry.path) + return { + ...entry, + mtimeMs: info.mtimeMs, + size: info.isFile() ? info.size : 0 + } + } catch { + return entry + } +} + +export async function listWorkspaceDirectory( + payload: WorkspaceDirectoryTarget +): Promise { + const result = await listWorkspaceDirectoryWithoutMetadata(payload) + if (!result.ok) return result + return { + ...result, + entries: await Promise.all(result.entries.map(withEntryMetadata)) + } +} From b6c954d696a4adcc5ba76d7238ecfec1c98fda22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?luoye=E3=82=B9=E3=82=AD?= <100058663+luoye520ww@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:54:01 +0800 Subject: [PATCH 14/17] fix(files): scan workspace for recent modified files --- .../src/components/chat/ChatFileTreePanel.tsx | 130 ++++++++++++++---- 1 file changed, 105 insertions(+), 25 deletions(-) diff --git a/src/renderer/src/components/chat/ChatFileTreePanel.tsx b/src/renderer/src/components/chat/ChatFileTreePanel.tsx index c21bf5515..6b3ce41d3 100644 --- a/src/renderer/src/components/chat/ChatFileTreePanel.tsx +++ b/src/renderer/src/components/chat/ChatFileTreePanel.tsx @@ -61,8 +61,17 @@ type ContextMenuState = { type FileTreeSortMode = 'name' | 'modified' +type RecentScanState = { + entries: WorkspaceEntry[] + loading: boolean + error: string | null +} + const ROOT_PATH = '' const IGNORED_DIRS = new Set(['.git', '.hg', '.svn', 'node_modules']) +const RECENT_FILE_LIMIT = 8 +const RECENT_SCAN_MAX_ENTRIES = 2_000 +const RECENT_SCAN_MAX_DEPTH = 8 function normalizePath(path: string): string { return path.replaceAll('\\', '/').replace(/\/+$/g, '') @@ -88,21 +97,32 @@ function entryReference(entry: WorkspaceEntry, workspaceRoot: string): ChatFileT } } -function compareEntriesByName(left: WorkspaceEntry, right: WorkspaceEntry): number { +export function compareChatFileTreeEntriesByName(left: WorkspaceEntry, right: WorkspaceEntry): number { if (left.type !== right.type) return left.type === 'directory' ? -1 : 1 return left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: 'base' }) } -function compareEntriesByModified(left: WorkspaceEntry, right: WorkspaceEntry): number { +export function compareChatFileTreeEntriesByModified(left: WorkspaceEntry, right: WorkspaceEntry): number { if (left.type !== right.type) return left.type === 'directory' ? -1 : 1 const leftTime = left.mtimeMs ?? 0 const rightTime = right.mtimeMs ?? 0 if (leftTime !== rightTime) return rightTime - leftTime - return compareEntriesByName(left, right) + return compareChatFileTreeEntriesByName(left, right) +} + +export function sortChatFileTreeEntries(entries: WorkspaceEntry[], mode: FileTreeSortMode): WorkspaceEntry[] { + return [...entries].sort(mode === 'modified' ? compareChatFileTreeEntriesByModified : compareChatFileTreeEntriesByName) } -function sortEntries(entries: WorkspaceEntry[], mode: FileTreeSortMode): WorkspaceEntry[] { - return [...entries].sort(mode === 'modified' ? compareEntriesByModified : compareEntriesByName) +function sortRecentFiles(entries: WorkspaceEntry[]): WorkspaceEntry[] { + return [...entries] + .filter(isChatFileTreePreviewableEntry) + .sort((left, right) => { + const leftTime = left.mtimeMs ?? 0 + const rightTime = right.mtimeMs ?? 0 + if (leftTime !== rightTime) return rightTime - leftTime + return compareChatFileTreeEntriesByName(left, right) + }) } export function isChatFileTreeIgnoredDirectory(name: string): boolean { @@ -129,6 +149,7 @@ export function ChatFileTreePanel({ const [directories, setDirectories] = useState>({}) const [contextMenu, setContextMenu] = useState(null) const [sortMode, setSortMode] = useState('name') + const [recentScan, setRecentScan] = useState({ entries: [], loading: false, error: null }) const menuRef = useRef(null) const root = workspaceRoot.trim() const rootName = useMemo(() => workspaceDisplayName(root), [root]) @@ -137,6 +158,7 @@ export function ChatFileTreePanel({ setExpanded(new Set([ROOT_PATH])) setDirectories({}) setContextMenu(null) + setRecentScan({ entries: [], loading: false, error: null }) }, [root]) const loadDirectory = useCallback((path: string): void => { @@ -181,6 +203,62 @@ export function ChatFileTreePanel({ } }, [directories, expanded, loadDirectory, root]) + useEffect(() => { + if (!root || typeof window.kunGui?.listWorkspaceDirectory !== 'function') return + let cancelled = false + setRecentScan({ entries: [], loading: true, error: null }) + + const scanDirectory = async ( + path: string, + depth: number, + collected: WorkspaceEntry[], + seenDirectories: Set + ): Promise => { + if (cancelled || depth > RECENT_SCAN_MAX_DEPTH || collected.length >= RECENT_SCAN_MAX_ENTRIES) return + const directoryKey = pathKey(path || root) + if (seenDirectories.has(directoryKey)) return + seenDirectories.add(directoryKey) + const result = await window.kunGui.listWorkspaceDirectory({ workspaceRoot: root, path: path || root }) + if (!result.ok) throw new Error(result.message) + for (const entry of result.entries) { + if (cancelled || collected.length >= RECENT_SCAN_MAX_ENTRIES) return + if (entry.type === 'directory') { + if (!isChatFileTreeIgnoredDirectory(entry.name)) { + await scanDirectory(entry.path, depth + 1, collected, seenDirectories) + } + continue + } + if (isChatFileTreePreviewableEntry(entry)) collected.push(entry) + } + } + + void (async () => { + try { + const collected: WorkspaceEntry[] = [] + await scanDirectory(root, 0, collected, new Set()) + if (!cancelled) { + setRecentScan({ + entries: sortRecentFiles(collected).slice(0, RECENT_FILE_LIMIT), + loading: false, + error: null + }) + } + } catch (error) { + if (!cancelled) { + setRecentScan({ + entries: [], + loading: false, + error: error instanceof Error ? error.message : String(error) + }) + } + } + })() + + return () => { + cancelled = true + } + }, [root]) + useEffect(() => { if (!contextMenu) return const onPointerDown = (event: PointerEvent): void => { @@ -200,20 +278,7 @@ export function ChatFileTreePanel({ }, [contextMenu]) const selectedKey = useMemo(() => pathKey(selectedPath ?? ''), [selectedPath]) - const recentEntries = useMemo(() => { - const seen = new Set() - return Object.values(directories) - .flatMap((state) => state.entries) - .filter(isChatFileTreePreviewableEntry) - .filter((entry) => { - const key = pathKey(entry.path) - if (seen.has(key)) return false - seen.add(key) - return true - }) - .sort(compareEntriesByModified) - .slice(0, 8) - }, [directories]) + const recentEntries = recentScan.entries if (!root) return null @@ -229,6 +294,7 @@ export function ChatFileTreePanel({ const refresh = (): void => { setDirectories({}) setExpanded(new Set([ROOT_PATH])) + setRecentScan({ entries: [], loading: false, error: null }) } const addReference = (entry: WorkspaceEntry): void => { @@ -306,7 +372,7 @@ export function ChatFileTreePanel({ : [] } - return sortEntries(state.entries, sortMode) + return sortChatFileTreeEntries(state.entries, sortMode) .filter((entry) => entry.type !== 'directory' || !isChatFileTreeIgnoredDirectory(entry.name)) .flatMap((entry) => { const isDirectory = entry.type === 'directory' @@ -363,6 +429,9 @@ export function ChatFileTreePanel({ const contextLabel = contextEntry?.type === 'directory' ? t('fileTreeAddFolderReference') : t('fileTreeAddFileReference') + const sortTitle = sortMode === 'modified' + ? t('fileTreeSortByName', { defaultValue: 'Sort by name' }) + : t('fileTreeSortByModifiedTime', { defaultValue: 'Sort by modified time' }) return (
@@ -372,8 +441,8 @@ export function ChatFileTreePanel({ actions={ <> setSortMode((mode) => mode === 'modified' ? 'name' : 'modified')} > @@ -389,11 +458,22 @@ export function ChatFileTreePanel({ } /> - {recentEntries.length ? ( + {recentEntries.length || recentScan.loading || recentScan.error ? (
-
Recent files
+
+ {t('fileTreeRecentModifiedFiles', { defaultValue: 'Recent modified files' })} +
- {recentEntries.map((entry) => ( + {recentScan.loading ? ( +
+ + {t('fileTreeScanningRecent', { defaultValue: 'Scanning workspace…' })} +
+ ) : recentScan.error ? ( +
+ {recentScan.error} +
+ ) : recentEntries.map((entry) => (