From ca754d6589e7fc7ecc1f30e1ccc5924d92e4eaf8 Mon Sep 17 00:00:00 2001 From: Iuliia Ivashko Date: Tue, 3 Mar 2026 18:35:03 +0200 Subject: [PATCH] feat: make chat file references clickable and responsive (#587) * docs: clarify named and quick Cloudflare tunnel usage * feat: make chat file paths openable from rendered responses * perf: speed up chat file-path links and open behavior * fix: open chat file references at mentioned lines * fix: prevent context panel flicker on blocked file opens --- README.md | 13 +- .../src/components/chat/MarkdownRenderer.tsx | 532 ++++++++++++++++++ .../components/layout/SidebarFilesTree.tsx | 11 +- packages/ui/src/components/views/DiffView.tsx | 15 +- .../ui/src/components/views/FilesView.tsx | 53 ++ packages/ui/src/index.css | 11 + packages/ui/src/lib/contextFileOpenGuard.ts | 66 +++ packages/ui/src/lib/fileOpenLimits.ts | 19 + packages/ui/src/stores/useUIStore.ts | 11 +- packages/web/README.md | 7 +- 10 files changed, 730 insertions(+), 8 deletions(-) create mode 100644 packages/ui/src/lib/contextFileOpenGuard.ts create mode 100644 packages/ui/src/lib/fileOpenLimits.ts diff --git a/README.md b/README.md index a8a0969b..3dbdea13 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ The whole project was built entirely with AI coding agents under my supervision. ### Web / PWA -- Cloudflare Quick Tunnel for instant remote access (`--try-cf-tunnel`) +- Cloudflare tunnel access with two modes: Quick Tunnel (CLI) and Named Tunnel (in-app settings) - One-scan onboarding with tunnel QR + password URL helpers - Mobile-first experience: optimized chat controls, keyboard-safe layouts, and attachment-friendly UI - Background notifications plus reliable cross-tab session activity tracking @@ -116,6 +116,8 @@ openchamber stop # Stop server openchamber update # Update to latest version ``` +Named Tunnel mode is configured in-app (Settings -> OpenChamber -> Tunnel). The CLI currently supports Quick Tunnel flags only. `--tunnel ` is not supported yet. + ### Desktop App (macOS) Download from [Releases](https://github.com/btriapitsyn/openchamber/releases). @@ -154,6 +156,15 @@ environment: | `qr` | Enable tunnel + QR code | | `password` | Enable tunnel + password in URL | +### Named Cloudflare Tunnel (persistent hostname) + +OpenChamber also supports Named Tunnel mode for more reliable long-lived access with your Cloudflare account and custom hostname. + +- Configure it in-app at **Settings -> OpenChamber -> Tunnel** and switch mode to **Named**. +- Named tunnels require a domain in your Cloudflare account. +- Cloudflare setup guide: https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/get-started/create-remote-tunnel/ +- CLI note: `--tunnel ` is not supported yet. + **Data Directory Permission Note:** The `data/` directory is mounted into the container for persistent storage (config, sessions, SSH keys, workspaces). Before running, ensure the directory exists and has proper permissions: ```bash diff --git a/packages/ui/src/components/chat/MarkdownRenderer.tsx b/packages/ui/src/components/chat/MarkdownRenderer.tsx index f51e87b8..eb8446da 100644 --- a/packages/ui/src/components/chat/MarkdownRenderer.tsx +++ b/packages/ui/src/components/chat/MarkdownRenderer.tsx @@ -17,6 +17,8 @@ import { getDefaultTheme } from '@/lib/theme/themes'; import type { ToolPopupContent } from './message/types'; import { useUIStore } from '@/stores/useUIStore'; import { useDeviceInfo } from '@/lib/device'; +import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; const withStableStringId = (value: T, id: string): T => { const existingPrimitive = (value as Record)[Symbol.toPrimitive]; @@ -682,6 +684,530 @@ interface MarkdownRendererProps { } const MERMAID_BLOCK_SELECTOR = '[data-streamdown="mermaid-block"]'; +const FILE_LINK_SELECTOR = '[data-openchamber-file-link="true"]'; + +type ParsedFileReference = { + path: string; + line?: number; + column?: number; +}; + +const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; +const WINDOWS_UNC_PATH_PATTERN = /^\\\\[^\\]+\\[^\\]+/; +const KNOWN_FILE_BASENAMES = new Set([ + 'dockerfile', + 'makefile', + 'readme', + 'license', + '.env', + '.gitignore', + '.npmrc', +]); +const KNOWN_BASENAME_PATTERN = Array.from(KNOWN_FILE_BASENAMES) + .map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('|'); + +const normalizePath = (value: string): string => { + const source = (value || '').trim(); + if (!source) { + return ''; + } + + const withSlashes = source.replace(/\\/g, '/'); + const hadUncPrefix = withSlashes.startsWith('//'); + + let normalized = withSlashes.replace(/\/+/g, '/'); + if (hadUncPrefix && !normalized.startsWith('//')) { + normalized = `/${normalized}`; + } + + const isUnixRoot = normalized === '/'; + const isWindowsDriveRoot = /^[A-Za-z]:\/$/.test(normalized); + if (!isUnixRoot && !isWindowsDriveRoot) { + normalized = normalized.replace(/\/+$/, ''); + } + + return normalized; +}; + +const isAbsolutePath = (value: string): boolean => { + return value.startsWith('/') + || WINDOWS_DRIVE_PATH_PATTERN.test(value) + || WINDOWS_UNC_PATH_PATTERN.test(value) + || value.startsWith('//'); +}; + +const toAbsolutePath = (basePath: string, targetPath: string): string => { + const normalizedTarget = normalizePath(targetPath); + if (!normalizedTarget) { + return normalizePath(basePath); + } + + if (isAbsolutePath(normalizedTarget)) { + return normalizedTarget; + } + + const normalizedBase = normalizePath(basePath); + if (!normalizedBase) { + return normalizedTarget; + } + + const isWindowsDriveBase = /^[A-Za-z]:/.test(normalizedBase); + const prefix = isWindowsDriveBase ? normalizedBase.slice(0, 2) : ''; + const baseRemainder = isWindowsDriveBase ? normalizedBase.slice(2) : normalizedBase; + + const stack = baseRemainder.split('/').filter(Boolean); + const parts = normalizedTarget.split('/').filter(Boolean); + for (const part of parts) { + if (part === '.') { + continue; + } + if (part === '..') { + if (stack.length > 0) { + stack.pop(); + } + continue; + } + stack.push(part); + } + + if (isWindowsDriveBase) { + return `${prefix}/${stack.join('/')}`; + } + + return `/${stack.join('/')}`; +}; + +const trimPathCandidate = (value: string): string => { + let next = (value || '').trim(); + if (!next) { + return ''; + } + + if ((next.startsWith('`') && next.endsWith('`')) || (next.startsWith('"') && next.endsWith('"')) || (next.startsWith("'") && next.endsWith("'"))) { + next = next.slice(1, -1).trim(); + } + + next = next.replace(/[.,;!?]+$/g, ''); + + if (next.endsWith(')') && !next.includes('(')) { + next = next.slice(0, -1); + } + if (next.endsWith(']') && !next.includes('[')) { + next = next.slice(0, -1); + } + + return next; +}; + +const stripTrailingReference = (value: string): string => { + let next = trimPathCandidate(value); + if (!next) { + return ''; + } + + const semicolonIndex = next.indexOf(';'); + if (semicolonIndex >= 0) { + next = next.slice(0, semicolonIndex); + } + + next = next.replace(/#.*$/, ''); + + const extensionSuffixMatch = next.match(/^(.*\.[A-Za-z0-9_-]{1,16}):.*$/); + if (extensionSuffixMatch) { + next = extensionSuffixMatch[1] ?? next; + } + + const basenameSuffixMatch = KNOWN_BASENAME_PATTERN.length > 0 + ? next.match(new RegExp(`^(.*(?:/|^)(${KNOWN_BASENAME_PATTERN})):.*$`, 'i')) + : null; + if (basenameSuffixMatch) { + next = basenameSuffixMatch[1] ?? next; + } + + return trimPathCandidate(next); +}; + +const parseFileReference = (value: string): ParsedFileReference | null => { + const trimmed = trimPathCandidate(value); + if (!trimmed) { + return null; + } + + const semicolonIndex = trimmed.indexOf(';'); + const withoutSemicolonSuffix = semicolonIndex >= 0 + ? trimPathCandidate(trimmed.slice(0, semicolonIndex)) + : trimmed; + if (!withoutSemicolonSuffix) { + return null; + } + + const hashMatch = withoutSemicolonSuffix.match(/^(.*)#L(\d+)(?:C(\d+))?$/i); + if (hashMatch) { + const path = stripTrailingReference(hashMatch[1] ?? ''); + const line = Number.parseInt(hashMatch[2] ?? '', 10); + const column = hashMatch[3] ? Number.parseInt(hashMatch[3], 10) : undefined; + if (!path || !Number.isFinite(line)) { + return null; + } + + return { + path, + line, + column: Number.isFinite(column ?? Number.NaN) ? column : undefined, + }; + } + + const colonMatch = withoutSemicolonSuffix.match(/^(.*):(\d+)(?::(\d+))?$/); + if (colonMatch) { + const path = stripTrailingReference(colonMatch[1] ?? ''); + const line = Number.parseInt(colonMatch[2] ?? '', 10); + const column = colonMatch[3] ? Number.parseInt(colonMatch[3], 10) : undefined; + if (!path || !Number.isFinite(line)) { + return null; + } + + return { + path, + line, + column: Number.isFinite(column ?? Number.NaN) ? column : undefined, + }; + } + + const pathOnly = stripTrailingReference(withoutSemicolonSuffix); + if (!pathOnly) { + return null; + } + + return { path: pathOnly }; +}; + +const hasFileExtension = (path: string): boolean => { + const base = path.split('/').filter(Boolean).pop() ?? ''; + if (!base || base.endsWith('.')) { + return false; + } + return /\.[A-Za-z0-9_-]{1,16}$/.test(base); +}; + +const isLikelyFilePathValue = (path: string): boolean => { + if (!path || path.startsWith('--') || path.includes('://')) { + return false; + } + + if (/[<>]/.test(path) || /\s{2,}/.test(path)) { + return false; + } + + const normalized = normalizePath(path); + const baseName = normalized.split('/').filter(Boolean).pop() ?? normalized; + if (!baseName || baseName === '.' || baseName === '..') { + return false; + } + + const base = baseName.toLowerCase(); + if (KNOWN_FILE_BASENAMES.has(base) || (base.startsWith('.') && base.length > 1)) { + return true; + } + + return hasFileExtension(normalized); +}; + +const isLikelyFilePath = (value: string): boolean => { + const parsed = parseFileReference(value); + if (!parsed) { + return false; + } + return isLikelyFilePathValue(parsed.path); +}; + +const extractPathCandidateFromElement = (element: HTMLElement): string => { + if (element.tagName.toLowerCase() === 'a') { + const href = element.getAttribute('href')?.trim(); + if (href && isLikelyFilePath(href)) { + return href; + } + } + + return (element.textContent || '').trim(); +}; + +const getResolvedReference = (rawValue: string, effectiveDirectory: string): (ParsedFileReference & { resolvedPath: string }) | null => { + const parsed = parseFileReference(rawValue); + if (!parsed || !isLikelyFilePathValue(parsed.path)) { + return null; + } + + const resolvedPath = isAbsolutePath(parsed.path) + ? normalizePath(parsed.path) + : toAbsolutePath(effectiveDirectory, parsed.path); + if (!resolvedPath) { + return null; + } + + return { + ...parsed, + resolvedPath, + }; +}; + +const getContextDirectory = (effectiveDirectory: string, resolvedPath: string): string => { + const normalizedDirectory = normalizePath(effectiveDirectory); + if (normalizedDirectory) { + return normalizedDirectory; + } + + const normalizedPath = normalizePath(resolvedPath); + const parent = normalizedPath.replace(/\/[^/]*$/, ''); + return parent || normalizedPath; +}; + +const useFileReferenceInteractions = ({ + containerRef, + effectiveDirectory, + readFile, +}: { + containerRef: React.RefObject; + effectiveDirectory: string; + readFile?: (path: string) => Promise<{ content: string; path: string }>; +}) => { + const validationCacheRef = React.useRef>(new Map()); + const inFlightValidationsRef = React.useRef>>(new Map()); + const annotationPassRef = React.useRef(0); + const annotationDebounceRef = React.useRef(null); + const isValidationSweepRunningRef = React.useRef(false); + + React.useEffect(() => { + const container = containerRef.current; + if (!container) { + return; + } + + let disposed = false; + + const isPathResolvable = async (resolvedPath: string): Promise => { + const cache = validationCacheRef.current; + if (cache.has(resolvedPath)) { + return cache.get(resolvedPath) === true; + } + + const inFlight = inFlightValidationsRef.current.get(resolvedPath); + if (inFlight) { + return inFlight; + } + + const checkPromise = (async () => { + try { + if (!readFile) { + return false; + } + await readFile(resolvedPath); + cache.set(resolvedPath, true); + return true; + } catch { + cache.set(resolvedPath, false); + return false; + } finally { + inFlightValidationsRef.current.delete(resolvedPath); + } + })(); + + inFlightValidationsRef.current.set(resolvedPath, checkPromise); + return checkPromise; + }; + + const clearCandidateLinkAttrs = (candidate: HTMLElement) => { + candidate.removeAttribute('data-openchamber-file-link'); + candidate.removeAttribute('data-openchamber-file-ref'); + candidate.removeAttribute('data-openchamber-file-path'); + if (candidate.getAttribute('title') === 'Open file') { + candidate.removeAttribute('title'); + } + if (candidate.tagName.toLowerCase() !== 'a') { + candidate.removeAttribute('role'); + candidate.removeAttribute('tabindex'); + } + }; + + const applyCandidateLinkAttrs = (candidate: HTMLElement, rawCandidate: string, resolvedPath: string) => { + candidate.setAttribute('data-openchamber-file-link', 'true'); + candidate.setAttribute('data-openchamber-file-ref', rawCandidate); + candidate.setAttribute('data-openchamber-file-path', resolvedPath); + candidate.setAttribute('title', 'Open file'); + if (candidate.tagName.toLowerCase() !== 'a') { + candidate.setAttribute('role', 'button'); + candidate.setAttribute('tabindex', '0'); + } + }; + + const runValidationSweep = async (paths: string[], expectedPassID: number) => { + if (isValidationSweepRunningRef.current || paths.length === 0) { + return; + } + + isValidationSweepRunningRef.current = true; + const maxConcurrent = 3; + let cursor = 0; + + const worker = async () => { + while (!disposed && cursor < paths.length) { + const index = cursor; + cursor += 1; + const pathToCheck = paths[index]; + if (!pathToCheck) { + continue; + } + await isPathResolvable(pathToCheck); + } + }; + + try { + await Promise.all(Array.from({ length: Math.min(maxConcurrent, paths.length) }, () => worker())); + } finally { + isValidationSweepRunningRef.current = false; + } + + if (!disposed && annotationPassRef.current === expectedPassID) { + void annotateFileLinks(); + } + }; + + const annotateFileLinks = async () => { + const passID = annotationPassRef.current + 1; + annotationPassRef.current = passID; + const candidates = container.querySelectorAll('[data-streamdown="inline-code"], a'); + const unresolvedPaths = new Set(); + + for (const candidate of Array.from(candidates)) { + const rawCandidate = extractPathCandidateFromElement(candidate); + const resolved = getResolvedReference(rawCandidate, effectiveDirectory); + if (!resolved) { + clearCandidateLinkAttrs(candidate); + continue; + } + + if (annotationPassRef.current !== passID) { + return; + } + + const cachedResult = validationCacheRef.current.get(resolved.resolvedPath); + if (cachedResult === true) { + applyCandidateLinkAttrs(candidate, rawCandidate, resolved.resolvedPath); + continue; + } + + clearCandidateLinkAttrs(candidate); + if (cachedResult !== false) { + unresolvedPaths.add(resolved.resolvedPath); + } + } + + if (unresolvedPaths.size > 0) { + void runValidationSweep(Array.from(unresolvedPaths), passID); + } + }; + + const openFileReference = async (sourceElement: HTMLElement): Promise => { + const raw = sourceElement.getAttribute('data-openchamber-file-ref') || extractPathCandidateFromElement(sourceElement); + const resolved = getResolvedReference(raw, effectiveDirectory); + if (!resolved) { + return false; + } + + const isResolvable = await isPathResolvable(resolved.resolvedPath); + if (!isResolvable) { + sourceElement.removeAttribute('data-openchamber-file-link'); + sourceElement.removeAttribute('data-openchamber-file-ref'); + sourceElement.removeAttribute('data-openchamber-file-path'); + if (sourceElement.getAttribute('title') === 'Open file') { + sourceElement.removeAttribute('title'); + } + return false; + } + + const contextDirectory = getContextDirectory(effectiveDirectory, resolved.resolvedPath); + const uiStore = useUIStore.getState(); + if (Number.isFinite(resolved.line ?? Number.NaN)) { + uiStore.openContextFileAtLine( + contextDirectory, + resolved.resolvedPath, + Math.max(1, Math.trunc(resolved.line as number)), + Number.isFinite(resolved.column ?? Number.NaN) + ? Math.max(1, Math.trunc(resolved.column as number)) + : 1, + ); + } else { + uiStore.openContextFile(contextDirectory, resolved.resolvedPath); + } + return true; + }; + + const handleClick = (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof Element)) { + return; + } + + const fileRefElement = target.closest(FILE_LINK_SELECTOR); + if (!(fileRefElement instanceof HTMLElement)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + void openFileReference(fileRefElement); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Enter' && event.key !== ' ') { + return; + } + + const target = event.target; + if (!(target instanceof HTMLElement) || target.getAttribute('data-openchamber-file-link') !== 'true') { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + void openFileReference(target); + }; + + void annotateFileLinks(); + + const observer = new MutationObserver(() => { + if (annotationDebounceRef.current !== null && typeof window !== 'undefined') { + window.clearTimeout(annotationDebounceRef.current); + } + if (typeof window === 'undefined') { + void annotateFileLinks(); + return; + } + annotationDebounceRef.current = window.setTimeout(() => { + annotationDebounceRef.current = null; + void annotateFileLinks(); + }, 120); + }); + observer.observe(container, { childList: true, subtree: true, characterData: true }); + + container.addEventListener('click', handleClick); + container.addEventListener('keydown', handleKeyDown); + + return () => { + disposed = true; + annotationPassRef.current += 1; + if (annotationDebounceRef.current !== null && typeof window !== 'undefined') { + window.clearTimeout(annotationDebounceRef.current); + } + annotationDebounceRef.current = null; + observer.disconnect(); + container.removeEventListener('click', handleClick); + container.removeEventListener('keydown', handleKeyDown); + }; + }, [containerRef, effectiveDirectory, readFile]); +}; const useMermaidInlineInteractions = ({ containerRef, @@ -786,9 +1312,12 @@ export const MarkdownRenderer: React.FC = ({ variant = 'assistant', onShowPopup, }) => { + const { files } = useRuntimeAPIs(); const streamdownContainerRef = React.useRef(null); + const effectiveDirectory = useEffectiveDirectory() ?? ''; const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(content), [content]); useMermaidInlineInteractions({ containerRef: streamdownContainerRef, mermaidBlocks, onShowPopup }); + useFileReferenceInteractions({ containerRef: streamdownContainerRef, effectiveDirectory, readFile: files.readFile }); const shikiThemes = useMarkdownShikiThemes(); const streamdownPlugins = useStreamdownPlugins(shikiThemes); @@ -844,11 +1373,13 @@ export const SimpleMarkdownRenderer: React.FC<{ onShowPopup, allowMermaidWheelZoom = false, }) => { + const { files } = useRuntimeAPIs(); const renderedContent = React.useMemo( () => (stripFrontmatter ? stripLeadingFrontmatter(content) : content), [content, stripFrontmatter], ); const streamdownContainerRef = React.useRef(null); + const effectiveDirectory = useEffectiveDirectory() ?? ''; const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(renderedContent), [renderedContent]); useMermaidInlineInteractions({ containerRef: streamdownContainerRef, @@ -856,6 +1387,7 @@ export const SimpleMarkdownRenderer: React.FC<{ onShowPopup, allowWheelZoom: allowMermaidWheelZoom, }); + useFileReferenceInteractions({ containerRef: streamdownContainerRef, effectiveDirectory, readFile: files.readFile }); const shikiThemes = useMarkdownShikiThemes(); const streamdownPlugins = useStreamdownPlugins(shikiThemes); diff --git a/packages/ui/src/components/layout/SidebarFilesTree.tsx b/packages/ui/src/components/layout/SidebarFilesTree.tsx index 07081577..11eedb5c 100644 --- a/packages/ui/src/components/layout/SidebarFilesTree.tsx +++ b/packages/ui/src/components/layout/SidebarFilesTree.tsx @@ -47,6 +47,7 @@ import { copyTextToClipboard } from '@/lib/clipboard'; import { cn } from '@/lib/utils'; import { opencodeClient } from '@/lib/opencode/client'; import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; +import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard'; type FileNode = { name: string; @@ -590,13 +591,19 @@ export const SidebarFilesTree: React.FC = () => { // --- File operations --- - const handleOpenFile = React.useCallback((node: FileNode) => { + const handleOpenFile = React.useCallback(async (node: FileNode) => { if (!root) return; + const openValidation = await validateContextFileOpen(files, node.path); + if (!openValidation.ok) { + toast.error(getContextFileOpenFailureMessage(openValidation.reason)); + return; + } + setSelectedPath(root, node.path); addOpenPath(root, node.path); openContextFile(root, node.path); - }, [addOpenPath, openContextFile, root, setSelectedPath]); + }, [addOpenPath, files, openContextFile, root, setSelectedPath]); const toggleDirectory = React.useCallback(async (dirPath: string) => { const normalized = normalizePath(dirPath); diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx index 9560ad9d..813f2965 100644 --- a/packages/ui/src/components/views/DiffView.tsx +++ b/packages/ui/src/components/views/DiffView.tsx @@ -16,6 +16,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Button } from '@/components/ui/button'; +import { toast } from '@/components/ui'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { getLanguageFromExtension, isImageFile } from '@/lib/toolHelpers'; @@ -25,6 +26,7 @@ import type { DiffViewMode } from '@/components/chat/message/types'; import { PierreDiffViewer } from './PierreDiffViewer'; import { useDeviceInfo } from '@/lib/device'; import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; +import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard'; // Minimum width for side-by-side diff view (px) const SIDE_BY_SIDE_MIN_WIDTH = 1100; @@ -908,7 +910,7 @@ export const DiffView: React.FC = ({ pinSelectedFileHeaderToTopOnNavigate = false, showOpenInEditorAction = false, }) => { - const { git } = useRuntimeAPIs(); + const { git, files } = useRuntimeAPIs(); const effectiveDirectory = useEffectiveDirectory(); const { screenWidth, isMobile } = useDeviceInfo(); @@ -1407,16 +1409,23 @@ export const DiffView: React.FC = ({ ? 1 : getFirstChangedModifiedLine(diffForNavigation.original, diffForNavigation.modified)); + const absolutePath = toAbsolutePath(effectiveDirectory, filePath); + const openValidation = await validateContextFileOpen(files, absolutePath); + if (!openValidation.ok) { + toast.error(getContextFileOpenFailureMessage(openValidation.reason)); + return; + } + openContextFileAtLine( effectiveDirectory, - toAbsolutePath(effectiveDirectory, filePath), + absolutePath, resolvedTargetLine, 1, ); } finally { setOpeningEditorFilePath((current) => (current === filePath ? null : current)); } - }, [effectiveDirectory, git, openContextFileAtLine, setDiff]); + }, [effectiveDirectory, files, git, openContextFileAtLine, setDiff]); const openSelectedFileInEditorAtChange = React.useCallback(async () => { if (!selectedFile) { diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index 78f40c98..6e352e86 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -649,6 +649,8 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const setMainTabGuard = useUIStore((state) => state.setMainTabGuard); const pendingFileNavigation = useUIStore((state) => state.pendingFileNavigation); const setPendingFileNavigation = useUIStore((state) => state.setPendingFileNavigation); + const pendingFileFocusPath = useUIStore((state) => state.pendingFileFocusPath); + const setPendingFileFocusPath = useUIStore((state) => state.setPendingFileFocusPath); // Global mouseup to end drag selection React.useEffect(() => { @@ -1842,6 +1844,57 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { textViewMode, ]); + React.useEffect(() => { + if (!pendingFileFocusPath || !root) { + return; + } + + const targetPath = normalizePath(pendingFileFocusPath); + if (!targetPath) { + setPendingFileFocusPath(null); + return; + } + + if (selectedFile?.path !== targetPath) { + if (selectedPath !== targetPath) { + setSelectedPath(root, targetPath); + } + return; + } + + if (fileLoading || loadedFilePath !== targetPath || fileError || isSelectedImage) { + return; + } + + if (canEdit && textViewMode !== 'edit') { + setTextViewMode('edit'); + return; + } + + if (canEdit) { + const view = editorViewRef.current; + if (!view) { + return; + } + view.focus(); + } + + setPendingFileFocusPath(null); + }, [ + canEdit, + fileError, + fileLoading, + isSelectedImage, + loadedFilePath, + pendingFileFocusPath, + root, + selectedFile?.path, + selectedPath, + setPendingFileFocusPath, + setSelectedPath, + textViewMode, + ]); + const nudgeEditorSelectionAboveKeyboard = React.useCallback((view: EditorView | null) => { if (!isMobile || !view || !view.hasFocus || typeof window === 'undefined') { return; diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index 2e0451be..ce55cab9 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -823,6 +823,17 @@ html:not(.dark) .chat-scroll { color: var(--markdown-link-hover, var(--primary)); } +.streamdown-content [data-openchamber-file-link="true"] { + color: var(--markdown-link, var(--primary)); + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; +} + +.streamdown-content [data-openchamber-file-link="true"]:hover { + color: var(--markdown-link-hover, var(--primary)); +} + /* Markdown blockquote - use theme colors */ .streamdown-content blockquote { color: var(--markdown-blockquote, var(--muted-foreground)); diff --git a/packages/ui/src/lib/contextFileOpenGuard.ts b/packages/ui/src/lib/contextFileOpenGuard.ts new file mode 100644 index 00000000..315b6791 --- /dev/null +++ b/packages/ui/src/lib/contextFileOpenGuard.ts @@ -0,0 +1,66 @@ +import type { FilesAPI } from '@/lib/api/types'; +import { MAX_OPEN_FILE_LINES, countLinesWithLimit } from '@/lib/fileOpenLimits'; + +export type ContextFileOpenFailureReason = 'too-large' | 'missing' | 'unreadable'; + +export type ContextFileOpenValidationResult = + | { ok: true } + | { ok: false; reason: ContextFileOpenFailureReason }; + +const classifyReadError = (error: unknown): ContextFileOpenFailureReason => { + const message = error instanceof Error ? error.message : String(error ?? ''); + const normalized = message.toLowerCase(); + + if ( + normalized.includes('file not found') + || normalized.includes('not found') + || normalized.includes('enoent') + || normalized.includes('no such file') + || normalized.includes('does not exist') + ) { + return 'missing'; + } + + return 'unreadable'; +}; + +const readFileContent = async (files: FilesAPI, path: string): Promise => { + if (files.readFile) { + const result = await files.readFile(path); + return result.content ?? ''; + } + + const response = await fetch(`/api/fs/read?path=${encodeURIComponent(path)}`); + if (!response.ok) { + const errorPayload = await response.json().catch(() => ({ error: response.statusText })); + throw new Error((errorPayload as { error?: string }).error || 'Failed to read file'); + } + + return response.text(); +}; + +export const validateContextFileOpen = async (files: FilesAPI, path: string): Promise => { + try { + const content = await readFileContent(files, path); + const lineCount = countLinesWithLimit(content, MAX_OPEN_FILE_LINES); + if (lineCount > MAX_OPEN_FILE_LINES) { + return { ok: false, reason: 'too-large' }; + } + + return { ok: true }; + } catch (error) { + return { ok: false, reason: classifyReadError(error) }; + } +}; + +export const getContextFileOpenFailureMessage = (reason: ContextFileOpenFailureReason): string => { + if (reason === 'too-large') { + return `File is too large to open (>${MAX_OPEN_FILE_LINES.toLocaleString()} lines)`; + } + + if (reason === 'missing') { + return 'File not found'; + } + + return 'Failed to open file'; +}; diff --git a/packages/ui/src/lib/fileOpenLimits.ts b/packages/ui/src/lib/fileOpenLimits.ts new file mode 100644 index 00000000..d4d1504c --- /dev/null +++ b/packages/ui/src/lib/fileOpenLimits.ts @@ -0,0 +1,19 @@ +export const MAX_OPEN_FILE_LINES = 5_000; + +export const countLinesWithLimit = (content: string, limit: number): number => { + if (!content) { + return 1; + } + + let lines = 1; + for (let index = 0; index < content.length; index += 1) { + if (content.charCodeAt(index) === 10) { + lines += 1; + if (lines > limit) { + return lines; + } + } + } + + return lines; +}; diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index bb2d6d1c..d17cb471 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -476,6 +476,7 @@ interface UIStore { sidebarOpenBeforeFullscreenTab: boolean | null; pendingDiffFile: string | null; pendingFileNavigation: PendingFileNavigation | null; + pendingFileFocusPath: string | null; isMobile: boolean; isKeyboardOpen: boolean; isCommandPaletteOpen: boolean; @@ -590,6 +591,7 @@ interface UIStore { setMainTabGuard: (guard: MainTabGuard | null) => void; setPendingDiffFile: (filePath: string | null) => void; setPendingFileNavigation: (navigation: PendingFileNavigation | null) => void; + setPendingFileFocusPath: (path: string | null) => void; navigateToDiff: (filePath: string) => void; consumePendingDiffFile: () => string | null; setIsMobile: (isMobile: boolean) => void; @@ -699,6 +701,7 @@ export const useUIStore = create()( sidebarOpenBeforeFullscreenTab: null, pendingDiffFile: null, pendingFileNavigation: null, + pendingFileFocusPath: null, isMobile: false, isKeyboardOpen: false, isCommandPaletteOpen: false, @@ -896,12 +899,13 @@ export const useUIStore = create()( openContextFile: (directory, filePath) => { const normalizedDirectory = normalizeDirectoryPath((directory || '').trim()); - const normalizedFilePath = (filePath || '').trim(); + const normalizedFilePath = normalizeContextTargetPath(filePath); if (!normalizedDirectory || !normalizedFilePath) { return; } get().openContextPanelTab(normalizedDirectory, { mode: 'file', targetPath: normalizedFilePath }); + get().setPendingFileFocusPath(normalizedFilePath); get().setPendingFileNavigation(null); }, @@ -915,6 +919,7 @@ export const useUIStore = create()( } get().openContextPanelTab(normalizedDirectory, { mode: 'file', targetPath: normalizedFilePath }); + get().setPendingFileFocusPath(null); get().setPendingFileNavigation({ path: normalizedFilePath, line: normalizedLine, @@ -1185,6 +1190,10 @@ export const useUIStore = create()( set({ pendingFileNavigation: navigation }); }, + setPendingFileFocusPath: (path) => { + set({ pendingFileFocusPath: path }); + }, + navigateToDiff: (filePath) => { const guard = get().mainTabGuard; if (guard && !guard('diff')) { diff --git a/packages/web/README.md b/packages/web/README.md index 8f3b089b..77c70435 100644 --- a/packages/web/README.md +++ b/packages/web/README.md @@ -31,12 +31,17 @@ openchamber # Start on port 3000 openchamber --port 8080 # Custom port openchamber --daemon # Background mode openchamber --ui-password secret # Password-protect UI +openchamber --try-cf-tunnel # Create a Cloudflare Quick Tunnel for remote access +openchamber --try-cf-tunnel --tunnel-qr # Show QR code for easy mobile access +openchamber --try-cf-tunnel --tunnel-password-url # Include password in URL for auto-login OPENCODE_PORT=4096 OPENCODE_SKIP_START=true openchamber # Connect to external OpenCode server OPENCODE_HOST=https://myhost:4096 OPENCODE_SKIP_START=true openchamber # Connect via custom host/HTTPS openchamber stop # Stop server openchamber update # Update to latest version ``` +Named Tunnel mode is configured in-app (Settings -> OpenChamber -> Tunnel). The CLI currently supports Quick Tunnel flags only. `--tunnel ` is not supported yet. + ### Environment Variables - `OPENCODE_HOST` - Full base URL of external OpenCode server, e.g. `http://hostname:4096` or `https://hostname:4096` (overrides `OPENCODE_PORT`) @@ -61,7 +66,7 @@ openchamber update # Update to latest version ### Web / PWA -- Cloudflare Quick Tunnel for instant remote access (`--try-cf-tunnel`) +- Cloudflare tunnel access with two modes: Quick Tunnel (CLI) and Named Tunnel (in-app settings) - One-scan onboarding with tunnel QR + password URL helpers - Mobile-first experience: optimized chat controls, keyboard-safe layouts, and attachment-friendly UI - Background notifications plus reliable cross-tab session activity tracking