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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@
- **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
- **ADDED**: previous/next change-block navigation with a scroll-aware counter in file tabs and View full, for Unified and Side-by-side diffs
- **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
- **FIXED**: collapsed sidebar handle stays at the window edge and sits lower, independently of GitHub's native navigation

## 0.4.1

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ Switching branches keeps Code's open paths and active file while loading the sel

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.

In diffs, the **Changes** counter and its arrow buttons cycle through blocks of additions/removals separated by unchanged lines. They work in file tabs and **View full**, in both layouts, revealing off-screen blocks without reloading the file. The counter follows scrolling and keeps the selected block current while it remains visible. Diffs without changed lines show `0 / 0` with disabled arrows.

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
5 changes: 4 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ In-page file tabs use `FILE_TABS` with `scope: 'code'|'review'` and an optional

`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.

`viewer/file-changes.js` groups contiguous non-context rows into change blocks once per diff render. Owned SVG icon buttons navigate cyclically through unified source boundaries; `file-content.js` maps them to the active layout, reveals virtualized rows and reuses tab-position persistence without fetching again. The counter follows the viewport, retaining a selected block while visible even when scrolling clamps at the file end. Scroll updates find nearby block boundaries by binary search. The controller adds no shortcuts, preferences or storage fields and is disposed with its viewer content.

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 @@ -113,7 +115,8 @@ 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.
- `viewer-changes.test.mjs`: change-block counts, cyclic navigation, virtual row reveal, manual scrolling, split projection, clamped positions and owned controls through the production View full dialog.
- `file-tabs.test.mjs`: file navigation and session restoration, including Split diff positions and change-block counters across file/review switches, Refresh and page reload, plus independent inline/dialog scrolling and navigation 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.
- `viewed.test.mjs`: Viewed marks through the bundled service worker in a VM with mocked APIs.
Expand Down
2 changes: 2 additions & 0 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ The **Unified / Side-by-side** switch is available in file tabs and **View full*

**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.

The **Changes** group provides previous/next change-block buttons and a counter in file tabs and **View full**, in both layouts. A block is a contiguous run of additions/removals, separated from other blocks by unchanged context. Navigation wraps and reveals virtualized rows without fetching revisions again; tab positions use the existing restoration behavior. The counter follows scrolling and retains the selected block while visible, including when the last block cannot reach the top of the viewport. Before the first block enters view it shows `0 / N`; diffs with no changed lines show `0 / 0` and disabled arrows. These controls add no shortcuts or preferences.

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
1 change: 0 additions & 1 deletion src/content/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,6 @@ export function mount() {
window,
'resize',
() => {
app.positionHandle();
app.requestTreeRender();
app.updateFileTabTheme();
},
Expand Down
1 change: 0 additions & 1 deletion src/content/native/header-buttons.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,6 @@ export function createHeaderButtons(app) {
if (!headerFrame)
headerFrame = requestFrame(() => {
headerFrame = null;
app.positionHandle();
if (['pull', 'commit'].includes(state.context?.kind)) injectHeaderButtons();
});
});
Expand Down
58 changes: 46 additions & 12 deletions src/content/sidebar.scss
Original file line number Diff line number Diff line change
Expand Up @@ -796,34 +796,34 @@ button:disabled {
}
.handle {
position: fixed;
top: 35%;
bottom: 17%;
pointer-events: auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 13px;
width: 34px;
padding: 12px 6px;
gap: 8px;
width: 26px;
padding: 8px 4px;
background: var(--muted-bg);
color: var(--text);
border: 0;
cursor: pointer;
font-size: 16px;
font-size: 12px;
font-weight: 650;
line-height: 1;
box-shadow: 1px 1px 8px #0001;

&[data-dock='left'] {
left: var(--handle-offset, 0px);
border-radius: 0 9px 9px 0;
left: 0;
border-radius: 0 7px 7px 0;
}
&[data-dock='right'] {
right: 0;
border-radius: 9px 0 0 9px;
border-radius: 7px 0 0 7px;
}
& .icon {
width: 18px;
height: 18px;
width: 14px;
height: 14px;
color: var(--muted);
}
&:hover {
Expand Down Expand Up @@ -969,6 +969,40 @@ button:disabled {
background: var(--selected);
color: var(--accent);
}
.diff-changes {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 2px;
padding: 2px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--muted-bg);
}
.diff-changes .icon-button {
width: 24px;
height: 24px;
}
.diff-changes-label {
padding-left: 6px;
color: var(--muted);
font-size: 11px;
white-space: nowrap;
}
.diff-changes-count {
min-width: 38px;
color: var(--muted);
font-size: 11px;
font-variant-numeric: tabular-nums;
text-align: center;
white-space: nowrap;
}
.diff-changes-previous .icon {
transform: rotate(-90deg);
}
.diff-changes-next .icon {
transform: rotate(90deg);
}
.viewer-find {
display: flex;
flex-wrap: wrap;
Expand Down Expand Up @@ -1272,8 +1306,8 @@ button:disabled {
.handle-grip {
display: flex;
flex-direction: column;
gap: 3px;
width: 14px;
gap: 2px;
width: 10px;
color: var(--muted);
}
.handle-grip::before,
Expand Down
32 changes: 3 additions & 29 deletions src/content/sidebar/layout.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import pageStyles from 'virtual:page-styles';
import {fontFamilies, preferences} from '../../shared/preferences.js';
import {isDark} from '../page.js';
import {createRenderEffect, on, onCleanup} from '../reactive.js';
import {createRenderEffect, on} from '../reactive.js';
import {listen} from '../reactive-dom.js';

/** Native Rouge token classes in GitLab's active code-theme stylesheet. */
Expand All @@ -30,13 +30,10 @@ const gitlabTokens = {
punctuation: 'p',
};

/** Creates the layout feature: `layout`, `updateTheme`, `positionHandle`, `setPreferences` and `updateCodeColors`. */
/** Creates the layout feature: `layout`, `updateTheme`, `setPreferences` and `updateCodeColors`. */
export function createLayout(app) {
const {state} = app;
const {host, pageStyle, panel, handle, resize, toastBox, pinButton, closeButton, searchHint} = app.view;
let nativeSidebar = null;
const nativeSidebarObserver = new ResizeObserver(positionHandle);
onCleanup(() => nativeSidebarObserver.disconnect());
listen(
document,
'load',
Expand Down Expand Up @@ -70,7 +67,6 @@ export function createLayout(app) {
panel.hidden = !available || !prefs.open;
handle.hidden = !available || prefs.open;
resize.hidden = !available || !prefs.open;
positionHandle();
pinButton.classList.toggle('active', prefs.pinned);
pinButton.setAttribute('aria-pressed', String(prefs.pinned));
const closeLabel = `Close sidebar${prefs.toggleShortcut ? ` · ${prefs.toggleShortcut}` : ''}`;
Expand Down Expand Up @@ -155,28 +151,6 @@ export function createLayout(app) {
}
}

/** Moves the collapsed edge handle beside GitHub's left navigation drawer when it is open. */
function positionHandle() {
const sidebar =
state.context?.provider === 'github' ? document.querySelector('[aria-label="Issues sidebar navigation"]') : null;
if (sidebar !== nativeSidebar) {
if (nativeSidebar) nativeSidebarObserver.unobserve(nativeSidebar);
nativeSidebar = sidebar;
if (sidebar) nativeSidebarObserver.observe(sidebar);
}
const rect = sidebar?.getBoundingClientRect();
const offset =
state.preferences.dock === 'left' &&
rect?.width > 0 &&
rect.height > 0 &&
rect.left >= -1 &&
rect.left < Math.max(1, handle.getBoundingClientRect().width) &&
rect.right < innerWidth / 2
? Math.ceil(Math.max(0, rect.right))
: 0;
host.style.setProperty('--handle-offset', `${offset}px`);
}

/**
* Applies preferences immediately and saves them in the service worker.
*
Expand All @@ -194,5 +168,5 @@ export function createLayout(app) {
undefined,
{name: 'native code colors'},
);
return {layout, positionHandle, setPreferences, updateTheme, updateCodeColors};
return {layout, setPreferences, updateTheme, updateCodeColors};
}
88 changes: 88 additions & 0 deletions src/content/viewer/file-changes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
* https://github.com/ziqq/Codetree
* Copyright (C) 2026 Anton Ustinoff
* https://github.com/ziqq/Codetree/blob/main/LICENSE
*
* Owned change-block navigation over validated, virtualized diff rows.
* Unified source boundaries also identify blocks in the paired split layout.
*/

import {button, el} from '../dom.js';
import {onCleanup} from '../reactive.js';

/**
* Adds cyclic previous/next navigation for contiguous additions and removals.
* A selected block stays current while visible, including when scrolling is
* clamped near the end of a file or several blocks fit in one viewport.
*
* @param {object} shell The owned diff shell.
* @param {Array<object>} rows Complete, validated unified diff rows.
* @param {{range: Function, project: Function, reveal: Function}} viewport Layout-aware row adapters.
* @param {Function} current Whether this render still owns the shell.
* @returns {{update: Function}} A viewport refresh callback.
*/
export function createFileChanges(shell, rows, viewport, current) {
const blocks = [];
for (let index = 0; index < rows.length; index++) {
if (rows[index].type === 'context') continue;
const start = index;
while (index + 1 < rows.length && rows[index + 1].type !== 'context') index++;
blocks.push({start, end: index + 1});
}
let active = -1;
let live = true;
const count = el('span', {class: 'diff-changes-count', role: 'status', 'aria-live': 'polite'});
const previous = button('chevron', 'Previous change block', () => select(-1), 'diff-changes-previous');
const next = button('chevron', 'Next change block', () => select(1), 'diff-changes-next');
const controls = el('div', {class: 'diff-changes', role: 'group', 'aria-label': 'Change blocks'}, [
el('span', {class: 'diff-changes-label', text: 'Changes'}),
count,
previous,
next,
]);
shell.actions.prepend(controls);
previous.disabled = next.disabled = !blocks.length;

/** Tests a block's exclusive boundaries in the current visual row coordinates. */
function visible(index, range) {
const block = blocks[index];
return block && viewport.project(block.start) < range.end && viewport.project(block.end) > range.start;
}

/** Follows scrolling without scanning every change block on each animation frame. */
function update() {
if (!live || !current()) return;
const range = viewport.range();
if (!visible(active, range)) {
let low = 0;
let high = blocks.length;
while (low < high) {
const middle = (low + high) >>> 1;
if (viewport.project(blocks[middle].start) <= range.start) low = middle + 1;
else high = middle;
}
active = low - 1;
if (!visible(active, range) && visible(low, range)) active = low;
}
const text = `${active + 1} / ${blocks.length}`;
if (count.textContent !== text) {
count.textContent = text;
count.setAttribute('aria-label', `Change block ${active + 1} of ${blocks.length}`);
}
}

/** Reveals the selected source block through the renderer, including off-screen rows. */
function select(direction) {
if (!live || !current() || !blocks.length) return;
update();
active =
active < 0 ? (direction > 0 ? 0 : blocks.length - 1) : (active + direction + blocks.length) % blocks.length;
viewport.reveal(blocks[active].start);
}

onCleanup(() => {
live = false;
controls.remove();
});
return {update};
}
22 changes: 22 additions & 0 deletions src/content/viewer/file-content.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ 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';
import {createFileChanges} from './file-changes.js';

/**
* Loads and renders both exact revisions of a changed file.
Expand Down Expand Up @@ -181,6 +182,7 @@ function renderRows(app, shell, rows, oldSyntax, newSyntax, current, diff = fals
let splitAnchor;
let frame = 0;
let search;
let changes;
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 @@ -246,6 +248,7 @@ function renderRows(app, shell, rows, oldSyntax, newSyntax, current, diff = fals
oldCode.style.width = `${overflow + oldPane.clientWidth}px`;
newCode.style.width = `${overflow + newPane.clientWidth}px`;
}
changes?.update();
}

/** Keeps paired panes equally wide so horizontal and vertical scroll ranges match. */
Expand Down Expand Up @@ -320,6 +323,25 @@ function renderRows(app, shell, rows, oldSyntax, newSyntax, current, diff = fals
);
}
if (!diff) return;
changes = createFileChanges(
shell,
rows,
{
range: () => {
const viewport = split ? oldPane : shell.body;
return {
start: viewport.scrollTop / rowHeight,
end: (viewport.scrollTop + viewport.clientHeight) / rowHeight,
};
},
project: index => (split ? (projection.unifiedToSplit[index] ?? projection.pairs.length) : index),
reveal: row => {
restorePosition({top: row * rowHeight, left: readPosition().left});
shell.onScroll?.();
},
},
current,
);
listen(window, 'resize', relayout);
createRenderEffect(on(() => app.state.preferences.fontSize, relayout));
const controls = el('div', {class: 'diff-layout', role: 'group', 'aria-label': 'Diff layout'});
Expand Down
Loading
Loading