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)) + } +} 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' 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')) diff --git a/src/renderer/src/components/chat/ChatFileTreePanel.test.ts b/src/renderer/src/components/chat/ChatFileTreePanel.test.ts index b7b1a1698..02f0aa6d3 100644 --- a/src/renderer/src/components/chat/ChatFileTreePanel.test.ts +++ b/src/renderer/src/components/chat/ChatFileTreePanel.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from 'vitest' import type { WorkspaceEntry } from '@shared/workspace-file' import { + compareChatFileTreeEntriesByModified, formatChatFileTreeUnsupportedMessage, isChatFileTreeIgnoredDirectory, - isChatFileTreePreviewableEntry + isChatFileTreePreviewableEntry, + sortChatFileTreeEntries } from './ChatFileTreePanel' function entry(overrides: Partial & Pick): WorkspaceEntry { @@ -11,7 +13,9 @@ function entry(overrides: Partial & Pick { it('formats unsupported preview titles without leaking UI state', () => { expect(formatChatFileTreeUnsupportedMessage('logo.png')).toContain('logo.png') }) + + it('sorts files by newest mtime before falling back to name', () => { + expect([ + entry({ name: 'old.md', type: 'file', mtimeMs: 100 }), + entry({ name: 'new.md', type: 'file', mtimeMs: 300 }), + entry({ name: 'same-b.md', type: 'file', mtimeMs: 200 }), + entry({ name: 'same-a.md', type: 'file', mtimeMs: 200 }) + ].sort(compareChatFileTreeEntriesByModified).map((item) => item.name)).toEqual([ + 'new.md', + 'same-a.md', + 'same-b.md', + 'old.md' + ]) + }) + + it('keeps directories before files in modified sort mode', () => { + expect(sortChatFileTreeEntries([ + entry({ name: 'new.md', type: 'file', mtimeMs: 300 }), + entry({ name: 'docs', type: 'directory', mtimeMs: 100 }), + entry({ name: 'old.md', type: 'file', mtimeMs: 50 }) + ], 'modified').map((item) => item.name)).toEqual(['docs', 'new.md', 'old.md']) + }) }) diff --git a/src/renderer/src/components/chat/ChatFileTreePanel.tsx b/src/renderer/src/components/chat/ChatFileTreePanel.tsx index 6c15d4c7a..6b3ce41d3 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,8 +59,19 @@ type ContextMenuState = { entry: WorkspaceEntry } | null +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, '') @@ -82,6 +97,34 @@ function entryReference(entry: WorkspaceEntry, workspaceRoot: string): ChatFileT } } +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' }) +} + +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 compareChatFileTreeEntriesByName(left, right) +} + +export function sortChatFileTreeEntries(entries: WorkspaceEntry[], mode: FileTreeSortMode): WorkspaceEntry[] { + return [...entries].sort(mode === 'modified' ? compareChatFileTreeEntriesByModified : compareChatFileTreeEntriesByName) +} + +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 { return IGNORED_DIRS.has(name.toLowerCase()) } @@ -105,6 +148,8 @@ 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 [recentScan, setRecentScan] = useState({ entries: [], loading: false, error: null }) const menuRef = useRef(null) const root = workspaceRoot.trim() const rootName = useMemo(() => workspaceDisplayName(root), [root]) @@ -113,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 => { @@ -157,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 => { @@ -176,6 +278,7 @@ export function ChatFileTreePanel({ }, [contextMenu]) const selectedKey = useMemo(() => pathKey(selectedPath ?? ''), [selectedPath]) + const recentEntries = recentScan.entries if (!root) return null @@ -191,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 => { @@ -198,6 +302,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 +372,7 @@ export function ChatFileTreePanel({ : [] } - return state.entries + return sortChatFileTreeEntries(state.entries, sortMode) .filter((entry) => entry.type !== 'directory' || !isChatFileTreeIgnoredDirectory(entry.name)) .flatMap((entry) => { const isDirectory = entry.type === 'directory' @@ -273,35 +385,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)] @@ -312,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 (
@@ -319,15 +439,57 @@ export function ChatFileTreePanel({ label={rootName || t('fileTreeTitle')} title={root} actions={ - - - + <> + setSortMode((mode) => mode === 'modified' ? 'name' : 'modified')} + > + {sortMode === 'modified' ? 'MT' : 'AZ'} + + + + + } /> + {recentEntries.length || recentScan.loading || recentScan.error ? ( +
+
+ {t('fileTreeRecentModifiedFiles', { defaultValue: 'Recent modified files' })} +
+
+ {recentScan.loading ? ( +
+ + {t('fileTreeScanningRecent', { defaultValue: 'Scanning workspace…' })} +
+ ) : recentScan.error ? ( +
+ {recentScan.error} +
+ ) : recentEntries.map((entry) => ( + + ))} +
+
+ ) : null}
{renderDirectory(ROOT_PATH, 0)}
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`(?(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 + 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); + } + .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); } + .ds-code-sidebar.kun-issue781-reader-mode { + position: fixed; + inset: 18px; + z-index: 9998; + 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; + } + ` + 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 tabKey(tab: Element | null): string { + return tab instanceof HTMLElement ? (tab.title || tab.textContent || '').trim() : '' +} + +function tabScopeKey(tab: Element | null): string { + if (!(tab instanceof HTMLElement)) return '' + const rawKey = tabKey(tab) + if (!rawKey) return '' + const sidebar = tab.closest('.ds-code-sidebar') + const explicitWorkspaceRoot = sidebar instanceof HTMLElement + ? sidebar.getAttribute('data-kun-workspace-root') + : '' + const explicitPreviewKey = tab.getAttribute('data-kun-preview-key') + const fallbackPageScope = `${window.location.origin}${window.location.pathname}` + return `${explicitWorkspaceRoot || fallbackPageScope}\n${explicitPreviewKey || rawKey}` + .replaceAll('\\', '/') + .toLowerCase() +} + +function activeTabKey(): string { + return tabScopeKey(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 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 applyPinnedClasses(): void { + const pinned = new Set(pinnedTabs()) + document.querySelectorAll('.ds-code-sidebar-tab').forEach((tab) => { + tab.classList.toggle('kun-issue781-pinned', pinned.has(tabScopeKey(tab))) + }) +} + +function closeIssue781Menu(): void { + menuEl?.remove() + menuEl = null +} + +function showTabMenu(tab: HTMLElement, x: number, y: number): void { + closeIssue781Menu() + const key = tabScopeKey(tab) + if (!key) return + 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` + const pinButton = document.createElement('button') + pinButton.type = 'button' + pinButton.textContent = pinned.has(key) ? label('unpinTab') : label('pinTab') + const closeOthersButton = document.createElement('button') + closeOthersButton.type = 'button' + closeOthersButton.textContent = label('closeOtherTabs') + 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 = tabScopeKey(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 +} + +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 setReadingMode(enabled: boolean): void { + const sidebar = document.querySelector('.ds-code-sidebar') + if (!(sidebar instanceof HTMLElement)) return + sidebar.classList.toggle('kun-issue781-reader-mode', enabled) + const button = sidebar.querySelector('.kun-issue781-expand-button') + if (button instanceof HTMLButtonElement) { + const title = enabled ? label('exitRead') : label('expandRead') + button.textContent = enabled ? label('exitRead') : label('read') + button.title = title + button.setAttribute('aria-label', title) + } +} + +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 ds-code-sidebar-icon-button' + button.title = label('expandRead') + button.setAttribute('aria-label', label('expandRead')) + button.textContent = label('read') + button.addEventListener('click', (event) => { + event.preventDefault() + event.stopPropagation() + const sidebar = document.querySelector('.ds-code-sidebar') + setReadingMode(!(sidebar instanceof HTMLElement && sidebar.classList.contains('kun-issue781-reader-mode'))) + }) + actions.insertBefore(button, actions.firstChild) +} + +function scheduleScan(): void { + if (scanTimer !== null) return + scanTimer = window.setTimeout(() => { + scanTimer = null + scanRenderedOutput() + enhancePreviewTabs() + enhanceScrollMemory() + enhanceReadingButton() + }, 120) +} + +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() + previewWorkspaceFile(fileTarget) + } +} + +export function installIssue781DocumentUsability(): void { + if (installed || typeof window === 'undefined' || typeof document === 'undefined') return + installed = true + injectStyle() + scanRenderedOutput() + enhancePreviewTabs() + enhanceScrollMemory() + enhanceReadingButton() + 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) + observer = new MutationObserver(() => { + scheduleScan() + }) + observer.observe(document.body, { childList: true, subtree: true }) +} + +installIssue781DocumentUsability() diff --git a/src/shared/workspace-file.ts b/src/shared/workspace-file.ts index ee178b5bf..cd05c4271 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 = {