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
This commit is contained in:
@@ -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 <config.yml>` 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 <config.yml>` 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
|
||||
|
||||
@@ -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 = <T extends object>(value: T, id: string): T => {
|
||||
const existingPrimitive = (value as Record<symbol, unknown>)[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<HTMLDivElement | null>;
|
||||
effectiveDirectory: string;
|
||||
readFile?: (path: string) => Promise<{ content: string; path: string }>;
|
||||
}) => {
|
||||
const validationCacheRef = React.useRef<Map<string, boolean>>(new Map());
|
||||
const inFlightValidationsRef = React.useRef<Map<string, Promise<boolean>>>(new Map());
|
||||
const annotationPassRef = React.useRef(0);
|
||||
const annotationDebounceRef = React.useRef<number | null>(null);
|
||||
const isValidationSweepRunningRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
|
||||
const isPathResolvable = async (resolvedPath: string): Promise<boolean> => {
|
||||
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<HTMLElement>('[data-streamdown="inline-code"], a');
|
||||
const unresolvedPaths = new Set<string>();
|
||||
|
||||
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<boolean> => {
|
||||
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<MarkdownRendererProps> = ({
|
||||
variant = 'assistant',
|
||||
onShowPopup,
|
||||
}) => {
|
||||
const { files } = useRuntimeAPIs();
|
||||
const streamdownContainerRef = React.useRef<HTMLDivElement>(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<HTMLDivElement>(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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<DiffViewProps> = ({
|
||||
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<DiffViewProps> = ({
|
||||
? 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) {
|
||||
|
||||
@@ -649,6 +649,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ 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<FilesViewProps> = ({ 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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<string> => {
|
||||
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<ContextFileOpenValidationResult> => {
|
||||
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';
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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<UIStore>()(
|
||||
sidebarOpenBeforeFullscreenTab: null,
|
||||
pendingDiffFile: null,
|
||||
pendingFileNavigation: null,
|
||||
pendingFileFocusPath: null,
|
||||
isMobile: false,
|
||||
isKeyboardOpen: false,
|
||||
isCommandPaletteOpen: false,
|
||||
@@ -896,12 +899,13 @@ export const useUIStore = create<UIStore>()(
|
||||
|
||||
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<UIStore>()(
|
||||
}
|
||||
|
||||
get().openContextPanelTab(normalizedDirectory, { mode: 'file', targetPath: normalizedFilePath });
|
||||
get().setPendingFileFocusPath(null);
|
||||
get().setPendingFileNavigation({
|
||||
path: normalizedFilePath,
|
||||
line: normalizedLine,
|
||||
@@ -1185,6 +1190,10 @@ export const useUIStore = create<UIStore>()(
|
||||
set({ pendingFileNavigation: navigation });
|
||||
},
|
||||
|
||||
setPendingFileFocusPath: (path) => {
|
||||
set({ pendingFileFocusPath: path });
|
||||
},
|
||||
|
||||
navigateToDiff: (filePath) => {
|
||||
const guard = get().mainTabGuard;
|
||||
if (guard && !guard('diff')) {
|
||||
|
||||
@@ -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 <config.yml>` 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
|
||||
|
||||
Reference in New Issue
Block a user