Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
- **ADDED**: `regex:` URL hide rules alongside existing wildcards, with validation before saving
- **ADDED**: in-page file tabs with branch-specific scroll positions, separate PR/MR/commit workspaces and trusted session-only metadata
- **ADDED**: Unified / Side-by-side full-file diffs in file tabs and View full, with synchronized panes and preserved source-line positions
- **ADDED**: Ctrl/Cmd+F literal search in open files and full-file diffs, with match highlights, counts and previous/next navigation across virtualized rows
- **CHANGED**: switching branches from a file page preserves its resolved file path instead of opening the branch root
- **CHANGED**: enable GitHub device-flow and GitLab PKCE sign-in with Codetree's registered public client IDs
- **CHANGED**: minimum Chrome version to 140 for trusted-only local and Sync storage access
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ Click a tree file to open it in Codetree's in-page workspace; clicking it again

Switching branches keeps Code's open paths and active file while loading the selected branch; each branch has separate scroll positions. A missing file stays open with Retry and, when available, an action to open it on the default branch. Each PR, MR and commit has a separate workspace; returning restores its tabs and positions. Selecting a branch from a review exits to Code and restores Code's workspace. Reloading the same native route preserves the selected inline tab; navigating to another native file selects that file.

Use **Ctrl/Cmd+F** or the viewer's search button to find literal text without case sensitivity in the open file or diff. The search covers the complete loaded text, including virtualized rows. **Enter / Shift+Enter** and the arrow buttons cycle through matches; **Escape** closes search before closing View full. Diff search includes old and new lines, counting shared context once. Tabs and View full have separate searches, cleared when their file changes; queries stay in page memory. At most 10,000 matches are listed, with a `+` count when more exist. Native text editors keep their normal find shortcut.

Workspace metadata belongs to the current browser tab, selected account, host and repository. It resets on extension/browser restart and is excluded from Sync. Limits are 32 open files, 256 saved positions and 50 workspaces, with 64 KiB per entry; file text is never stored with this metadata. In-page tabs are new beta behavior; installed-browser smoke verification is pending.

| Shortcut | Action |
Expand Down
3 changes: 3 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ In-page file tabs use `FILE_TABS` with `scope: 'code'|'review'` and an optional

`file-content.js` projects validated unified diff rows into paired old/new rows without re-fetching revisions or re-running highlighting. Both split panes virtualize the same row range and synchronize scrolling. The page-local `diffLayout` signal updates tabs and dialogs together; it is not a stored preference. Viewport adapters translate saved tab positions to/from unified row coordinates, keeping the existing `FILE_TABS` schema.

`viewer/file-search.js` owns the current file's find controls and capture-phase keyboard listener. It scans the complete validated row list with escaped, case-insensitive Unicode matching, retaining at most 10,000 source-offset ranges. Debounced scans yield between chunks and reject replies after query changes or disposal. Ranges are keyed by the original row objects, so split context is counted once and painted in both panes. `file-content.js` splits syntax text nodes at match boundaries and translates result navigation through the existing unified-coordinate viewport adapter. Queries and match metadata stay in the owned viewer, with no broker, storage or Sync changes.

View fields in `state.js` are signal-backed accessors, so factories keep the `state.x = value` syntax. `expanded` is replaced with a new Set and compared by contents. Generations, tree entries, the tree model and keyboard focus stay plain fields. `updateTree()` invalidates one tree derivation after structural changes; expansion and the debounced search invalidate it directly. Multi-field updates use `batch`. Files status is stored once; the visible `loading`/`error` accessors choose between Files and list status without copying on tab switches.

The app root owns sidebar bindings and global listeners. Each navigation disposes its page root, closes its viewer and branch popover, removes native buttons and clears pending folder requests. Page liveness checks complement request generations. Each viewer shell owns its scroll listener and frames, and releases them on close or replacement. `catchError` on the app root reports computation errors as a toast; feature actions run untracked. Settings uses the same primitives with separate preference, account, OAuth and Sync bindings and listens for stored preference changes.
Expand All @@ -110,6 +112,7 @@ See [Checks and releases](RELEASING.md) for CI and publication.

- `core.test.mjs`: full-file diff validation.
- `split-diff.test.mjs`: View full layout controls, aligned old/new rows, synchronized/clamped scrolling, source-line positions, font/viewport changes and disposal through the production viewer with synthetic geometry.
- `viewer-search.test.mjs`: file-search controls and shortcuts, literal/Unicode ranges across syntax tokens, complete virtualized-file navigation, result limits and stale-scan/disposal checks through the production viewer.
- `file-tabs.test.mjs`: file navigation and session restoration, including Split diff positions across file/review switches, Refresh and page reload, plus independent inline/dialog scrolling with a shared layout.
- `hosts.test.mjs`: request contexts built like the content script's, and rejected branch/path segments.
- `hide-rules.test.mjs`: regex and wildcard URL exclusions, validation, persisted rules, URL query/fragment boundaries and code-only scope.
Expand Down
2 changes: 2 additions & 0 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ The native button is inserted into a changed file's header. A rerendered header

The **Unified / Side-by-side** switch is available in file tabs and **View full**. Unified is the default; the selected layout is shared by both viewers until the page reloads. Side-by-side shows the old revision on the left and the new revision on the right, with aligned change blocks, empty gaps for unmatched additions/removals and synchronized scrolling. Switching reuses the loaded revisions and preserves the top source line. Saved tab positions use unified coordinates so reopening a file in either layout restores the same source location. Ordinary repository text and comments do not show this switch.

**Ctrl/Cmd+F** or the search button opens literal, case-insensitive search in the active file; an open View full dialog takes priority over inline tabs. Enter/Shift+Enter and previous/next buttons wrap through matches and reveal off-screen source rows horizontally and vertically. Search includes both revisions, counts unchanged context once and survives switching Unified/Side-by-side. Highlighting preserves syntax tokens and original Unicode offsets. Each viewer keeps its own query only while its current file is open; closing search hides highlights and retains the query for reopening. Native input fields keep browser/editor find behavior. Scanning yields between chunks, cancels stale queries and lists the first 10,000 matches with an explicit `+` count when more exist.

An unchanged rename loads the previous path at the base revision and the new path at the head revision. Added/deleted files use an empty opposite revision. Known binary extensions have disabled native buttons; encoding, binary content and size checks also run in the broker.

Changed text must have a complete patch that matches both source revisions. Missing/truncated or incompatible patches show an error with a native diff link. There is no fallback that presents an incomplete file diff as complete.
Expand Down
48 changes: 48 additions & 0 deletions src/content/sidebar.scss
Original file line number Diff line number Diff line change
Expand Up @@ -969,6 +969,54 @@ button:disabled {
background: var(--selected);
color: var(--accent);
}
.viewer-find {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
max-width: 100%;
min-width: 0;
}
.viewer-find[hidden],
.viewer-find-toggle[hidden] {
display: none;
}
.viewer-find-field {
margin: 0;
width: clamp(180px, 24vw, 320px);
flex: 1 1 220px;
min-width: 120px;
max-width: 100%;
}
.viewer-find-field:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent);
}
.viewer-find-count {
flex-shrink: 0;
color: var(--muted);
font-size: 11px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.viewer-find-previous .icon {
transform: rotate(-90deg);
}
.viewer-find-next .icon {
transform: rotate(90deg);
}
.file-match {
padding: 0;
border-radius: 2px;
background: var(--selected);
box-shadow: inset 0 -1px var(--accent);
color: inherit;
}
.file-match.active {
background: var(--accent);
color: var(--bg);
outline: 1px solid var(--accent);
}
.viewer[aria-label='Full-file diff'] .viewer-body {
background: var(--code-bg);
color: var(--code-text);
Expand Down
65 changes: 58 additions & 7 deletions src/content/viewer/file-content.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {providerName} from '../page.js';
import {createRenderEffect, on, onCleanup, runWithOwner} from '../reactive.js';
import {listen, requestFrame} from '../reactive-dom.js';
import {tokenize} from './syntax.js';
import {createFileSearch} from './file-search.js';

/**
* Loads and renders both exact revisions of a changed file.
Expand Down Expand Up @@ -128,20 +129,42 @@ function splitRows(rows) {
}

/** Creates one source row or an empty alignment gap, rendering syntax as text nodes only. */
function codeRow(row, side, oldSyntax, newSyntax) {
function codeRow(row, side, oldSyntax, newSyntax, search) {
if (!row) return el('div', {class: 'code-row diff-gap', 'aria-hidden': 'true'});
const text = el('span', {class: 'line-text'});
const old = side === 'old' || (side === 'unified' && row.type === 'removed');
const tokens = old ? oldSyntax.lines[row.oldLine - 1] : newSyntax.lines[row.newLine - 1];
const ranges = search.ranges(row);
let rangeIndex = 0;
/** Preserves token spans while splitting only their text at original match boundaries. */
function append(parent, start, end) {
while (start < end) {
while (rangeIndex < ranges.length && ranges[rangeIndex].end <= start) rangeIndex++;
const range = ranges[rangeIndex];
if (!range || range.start >= end) {
parent.append(document.createTextNode(row.text.slice(start, end)));
return;
}
if (range.start > start) parent.append(document.createTextNode(row.text.slice(start, range.start)));
const finish = Math.min(end, range.end);
parent.append(
el('mark', {
class: `file-match${range === search.active() ? ' active' : ''}`,
text: row.text.slice(Math.max(start, range.start), finish),
}),
);
start = finish;
}
}
let position = 0;
for (const token of tokens || []) {
text.append(
document.createTextNode(row.text.slice(position, token.start)),
el('span', {class: `syntax-${token.type}`, text: row.text.slice(token.start, token.end)}),
);
append(text, position, token.start);
const syntax = el('span', {class: `syntax-${token.type}`});
append(syntax, token.start, token.end);
text.append(syntax);
position = token.end;
}
text.append(document.createTextNode(row.text.slice(position)));
append(text, position, row.text.length);
const numbers = side === 'unified' ? [row.oldLine, row.newLine] : [old ? row.oldLine : row.newLine];
return el('div', {class: `code-row ${row.type}`}, [
...numbers.map(number => el('span', {class: 'line-number', text: number || ''})),
Expand All @@ -157,6 +180,7 @@ function renderRows(app, shell, rows, oldSyntax, newSyntax, current, diff = fals
let projection;
let splitAnchor;
let frame = 0;
let search;
const code = el('div', {class: 'code-spacer'});
const oldCode = el('div', {class: 'code-spacer'});
const newCode = el('div', {class: 'code-spacer'});
Expand Down Expand Up @@ -210,7 +234,7 @@ function renderRows(app, shell, rows, oldSyntax, newSyntax, current, diff = fals
const fragment = document.createDocumentFragment();
for (let index = start; index < end; index++) {
const row = split ? projection.pairs[index][side === 'old' ? 0 : 1] : rows[index];
const element = codeRow(row, side, oldSyntax, newSyntax);
const element = codeRow(row, side, oldSyntax, newSyntax, search);
element.style.top = `${index * rowHeight}px`;
fragment.append(element);
}
Expand Down Expand Up @@ -246,11 +270,38 @@ function renderRows(app, shell, rows, oldSyntax, newSyntax, current, diff = fals
restorePosition({top: line * rowHeight, left: position.left});
}

/** Reveals a search hit even when its row is not rendered, keeping split panes and tab metadata in sync. */
function reveal(match) {
restorePosition({top: match.row * rowHeight, left: readPosition().left});
const viewport = split ? (rows[match.row].type === 'removed' ? oldPane : newPane) : shell.body;
const marks = Array.from(viewport.querySelectorAll('.file-match.active'));
if (marks.length) {
// One match can span several syntax tokens; reveal their combined range.
const rects = marks.map(mark => mark.getBoundingClientRect());
const leftEdge = Math.min(...rects.map(rect => rect.left));
const rightEdge = Math.max(...rects.map(rect => rect.right));
const bounds = viewport.getBoundingClientRect();
if (leftEdge < bounds.left || rightEdge > bounds.right) {
const margin = Math.max(0, (viewport.clientWidth - (rightEdge - leftEdge)) / 2);
const left = Math.max(0, viewport.scrollLeft + leftEdge - bounds.left - margin);
for (const pane of split ? [oldPane, newPane] : [shell.body]) pane.scrollLeft = left;
}
}
shell.onScroll?.();
}

shell.readPosition = readPosition;
shell.restorePosition = restorePosition;
shell.relayout = relayout;
shell.body.replaceChildren(code);
runWithOwner(shell.owner, () => {
search = createFileSearch(
app,
shell,
rows,
{draw, reveal, row: () => Math.floor(readPosition().top / rowHeight)},
current,
);
for (const viewport of [shell.body, oldPane, newPane]) {
listen(
viewport,
Expand Down
Loading
Loading