diff --git a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz index baf92a5..64a2a26 100644 Binary files a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz and b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz differ diff --git a/public/app.js b/public/app.js index 63decb1..9f6cbfa 100644 --- a/public/app.js +++ b/public/app.js @@ -1949,6 +1949,70 @@ return current ? `${root}/${current}` : root; } + function normalizeAbsoluteDisplayPath(input) { + return String(input || '').trim().replace(/\\/g, '/').replace(/\/+$/, ''); + } + + function isAbsoluteDisplayPath(input) { + const value = normalizeAbsoluteDisplayPath(input); + return value.startsWith('/') || /^[A-Za-z]:\//.test(value); + } + + function getWorkspaceRelativePath(input) { + const filePath = normalizeAbsoluteDisplayPath(input); + if (!filePath) return ''; + if (!isAbsoluteDisplayPath(filePath)) return normalizeBrowserPath(filePath); + + const rootPath = normalizeAbsoluteDisplayPath(currentCwd); + if (!rootPath) return ''; + if (filePath === rootPath) return ''; + if (!filePath.startsWith(`${rootPath}/`)) return ''; + return normalizeBrowserPath(filePath.slice(rootPath.length + 1)); + } + + function parseLocalFileLinkHref(rawHref) { + const raw = String(rawHref || '').trim(); + if (!raw || raw.startsWith('#')) return null; + + let decoded = raw; + try { decoded = decodeURI(raw); } catch {} + + if (/^file:\/\//i.test(decoded)) { + try { decoded = decodeURI(new URL(decoded).pathname || decoded); } catch {} + } else if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(decoded) && !/^[A-Za-z]:[\\/]/.test(decoded)) { + return null; + } + + let line = 0; + let hasLineSuffix = false; + const hashMatch = decoded.match(/#L?(\d+)(?:\b|$)/i); + if (hashMatch) { + line = Number(hashMatch[1]) || 0; + decoded = decoded.slice(0, hashMatch.index); + } + + decoded = decoded.replace(/\\/g, '/'); + const lineMatch = decoded.match(/^(.+?):(\d+)(?::\d+)?$/); + if (lineMatch) { + decoded = lineMatch[1]; + line = Number(lineMatch[2]) || line; + hasLineSuffix = true; + } + + const filePath = normalizeAbsoluteDisplayPath(decoded); + if (!filePath) return null; + if (!isAbsoluteDisplayPath(filePath) && !/^\.{1,2}\//.test(filePath) && !filePath.includes('/') && !hasLineSuffix) return null; + + return { filePath, line }; + } + + function formatLocalFileLinkText(link, fallbackText) { + const rawText = String(fallbackText || '').trim(); + const baseText = rawText && rawText !== link.filePath ? rawText : (getPathLeaf(link.filePath) || link.filePath); + if (!link.line || new RegExp(`:${link.line}$`).test(baseText)) return baseText; + return `${baseText}:${link.line}`; + } + async function fetchAuthJson(url, options = {}) { await ensureAuthenticatedWs(); const response = await fetch(url, { @@ -3021,10 +3085,22 @@ } } - async function openFileBrowserFile(targetPath) { + function scrollFileBrowserPreviewToLine(lineNumber) { + const line = Number(lineNumber || 0); + const codeEl = fileBrowserState?.previewCodeEl; + if (!codeEl || line <= 0) return; + requestAnimationFrame(() => { + const styles = window.getComputedStyle(codeEl); + const lineHeight = parseFloat(styles.lineHeight) || (parseFloat(styles.fontSize) * 1.4) || 18; + codeEl.scrollTop = Math.max(0, (line - 4) * lineHeight); + }); + } + + async function openFileBrowserFile(targetPath, options = {}) { if (!fileBrowserState) return; const state = fileBrowserState; const normalizedPath = normalizeBrowserPath(targetPath); + const targetLine = Math.max(0, Number(options.line || 0) || 0); const requestId = ++state.previewRequestId; state.selectedFilePath = normalizedPath; syncFileBrowserSelection(); @@ -3040,11 +3116,13 @@ const metaParts = [formatFileSize(data.size || 0)]; if (data.updatedAt) metaParts.push(timeAgo(data.updatedAt)); if (data.truncated) metaParts.push(`仅显示前 ${formatFileSize(data.previewBytes || 0)}`); + if (targetLine) metaParts.push(`第 ${targetLine} 行`); state.previewMetaEl.textContent = metaParts.join(' · '); state.previewEmptyEl.hidden = true; state.previewCodeEl.hidden = false; state.previewCodeEl.textContent = data.content || ''; - setFileBrowserStatus(`已打开 ${data.name || '文件'}`); + scrollFileBrowserPreviewToLine(targetLine); + setFileBrowserStatus(`已打开 ${data.name || '文件'}${targetLine ? `:${targetLine}` : ''}`); } catch (err) { if (!fileBrowserState || state !== fileBrowserState || requestId !== state.previewRequestId) return; state.selectedFilePath = ''; @@ -4552,7 +4630,50 @@ }); } + function handleLocalFileLinkClick(event) { + event.preventDefault(); + event.stopPropagation(); + + const link = event.currentTarget; + const filePath = link?.dataset?.filePath || ''; + const line = Number(link?.dataset?.fileLine || 0) || 0; + const relativePath = getWorkspaceRelativePath(filePath); + if (!relativePath) { + showToast(currentCwd ? '文件不在当前会话工作目录内' : '当前会话没有可浏览的工作目录'); + return; + } + + showFileBrowser(); + if (!fileBrowserState) return; + + loadFileBrowserDirectory(getBrowserParentPath(relativePath), { preservePreview: true }); + openFileBrowserFile(relativePath, { line }); + } + + function hydrateLocalFileLinks(root) { + if (!root) return; + const links = root.matches?.('a') + ? [root] + : Array.from(root.querySelectorAll('a')); + + links.forEach((link) => { + if (link.dataset.localFileHydrated === '1') return; + const parsed = parseLocalFileLinkHref(link.getAttribute('href') || ''); + if (!parsed) return; + + link.dataset.localFileHydrated = '1'; + link.dataset.localFileLink = 'true'; + link.dataset.filePath = parsed.filePath; + link.dataset.fileLine = parsed.line ? String(parsed.line) : ''; + link.classList.add('local-file-link'); + link.title = parsed.line ? `${parsed.filePath}:${parsed.line}` : parsed.filePath; + link.textContent = formatLocalFileLinkText(parsed, link.textContent); + link.addEventListener('click', handleLocalFileLinkClick); + }); + } + function hydrateRenderedMarkdown(root) { + hydrateLocalFileLinks(root); hydrateMermaidBlocks(root); } diff --git a/public/style.css b/public/style.css index d080b7c..bdfd7aa 100644 --- a/public/style.css +++ b/public/style.css @@ -2681,6 +2681,27 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span { .msg-bubble h3 { font-size: 1.05em; } .msg-bubble a { color: var(--info); text-decoration: none; } .msg-bubble a:hover { text-decoration: underline; } +.msg-bubble a.local-file-link { + display: inline-flex; + align-items: baseline; + max-width: 100%; + overflow-wrap: anywhere; + font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; + font-size: 0.95em; + color: var(--accent); +} +.msg-bubble a.local-file-link::before { + content: 'file'; + margin-right: 4px; + padding: 0 4px; + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-muted); + font-family: inherit; + font-size: 0.76em; + line-height: 1.35; + text-decoration: none; +} .msg-bubble blockquote { border-left: 3px solid var(--border-color); padding-left: 12px; diff --git a/scripts/regression.js b/scripts/regression.js index b6a2a57..5b328a4 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -565,6 +565,16 @@ function assertFrontendCcwebPromptContract() { assert(source.includes("className = 'ccweb-prompt-answer'"), 'Each ccweb prompt question should expose an editable answer textarea'); } +function assertFrontendMarkdownLinkContract() { + const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8'); + const styleSource = fs.readFileSync(path.join(REPO_DIR, 'public', 'style.css'), 'utf8'); + assert(source.includes('function parseLocalFileLinkHref(rawHref)'), 'Frontend should parse local file hrefs separately from web links'); + assert(source.includes("link.dataset.localFileLink = 'true';"), 'Frontend should mark local file links with data-local-file-link'); + assert(source.includes('hydrateLocalFileLinks(root);'), 'Rendered markdown should hydrate local file links'); + assert(source.includes('openFileBrowserFile(relativePath, { line });'), 'Local file links should open the file browser with line metadata'); + assert(styleSource.includes('.msg-bubble a.local-file-link'), 'Local file links should have a distinct message style'); +} + function assertFrontendMcpReloadContract() { const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8'); assert(source.includes('function mcpStartupStatusToastText(status)'), 'Frontend should format MCP startup status toast text'); @@ -582,6 +592,7 @@ async function main() { assertFrontendGenerationControlsContract(); assertFrontendComposerMcpContract(); assertFrontendCcwebPromptContract(); + assertFrontendMarkdownLinkContract(); assertMockCodexAppPromptUserNotTextTriggered(); assertFrontendMcpReloadContract();