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 @@ -11,6 +11,7 @@
- **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
- **ADDED**: Viewed & next in PR/MR file tabs and View full saves the current mark before opening the next listed unviewed file
- **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 @@ -63,6 +63,8 @@ Use **Ctrl/Cmd+F** or the viewer's search button to find literal text without ca

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.

In PR/MR file tabs and **View full**, **Viewed & next** saves the current file's Viewed mark, then opens the next unviewed file in the same viewer. It follows the full listed changed-file order, wraps to earlier files and ignores sidebar search and **Only unviewed**. An already viewed file offers **Next unviewed** without another write. Failed marks keep the file open; failed navigation keeps a successful mark and offers a retry. When all listed files are viewed, the current file stays open with a completion message. GitHub uses its available Viewed mode; GitLab marks remain local.

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
4 changes: 3 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ Repository requests are dispatched to `providers/github.js` or `providers/gitlab
| `navigation.js` | Page loads, header, tabs, Refresh. |
| `sidebar/view.js`, `layout.js`, `render.js` | Shadow DOM elements, placement and page padding, tab rendering. |
| `sidebar/files.js`, `tree.js` | Tree loading and lazy folders; virtualized rows, keyboard navigation, Viewed marks. |
| `viewed.js`, `viewer/file-review.js` | Revision-bound Viewed publication and owned PR/MR Viewed-and-next controls shared by the tree, file tabs and View full. |
| `sidebar/file-icons.js` | file-icons rule matching and glyph rendering. |
| `sidebar/branches.js`, `pulls.js`, `bookmarks.js` | Branch popover, request list and filters, bookmarks. |
| `viewer/` | Full-file diff and comments dialog; lexical highlighting. |
Expand Down Expand Up @@ -116,7 +117,8 @@ See [Checks and releases](RELEASING.md) for CI and publication.
- `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.
- `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.
- `viewer-review.test.mjs`: Viewed-and-next acknowledgement ordering, listed-file traversal, completion, retries and stale-view disposal through View full in both layouts and provider modes.
- `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. Viewed-and-next coverage checks confirmed marks, metadata-save retries, manual navigation intent and stale replies.
- `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
76 changes: 69 additions & 7 deletions src/content/sidebar.scss
Original file line number Diff line number Diff line change
Expand Up @@ -906,11 +906,11 @@ button:disabled {
flex-direction: column;
}
.viewer-header {
padding: 13px 16px;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
display: flex;
flex-wrap: wrap;
gap: 10px;
gap: 12px;
align-items: center;
}
.viewer-heading {
Expand All @@ -928,18 +928,53 @@ button:disabled {
display: block;
font-size: 11px;
color: var(--muted);
margin-top: 5px;
margin-top: 4px;
}
.viewer-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 5px;
justify-content: flex-end;
gap: 8px;
margin-left: auto;
min-width: 0;
max-width: 100%;
}
.viewer-tools {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
max-width: 100%;
}
.viewer-actions > .icon-button,
.viewer-tools > .icon-button,
.viewer-find > .icon-button {
width: 30px;
height: 30px;
flex-shrink: 0;
}
.viewer-actions > .icon-button,
.viewer-tools > .icon-button {
margin: 0;
padding: 0;
}
.viewer-actions a {
height: 30px;
margin: 0;
padding: 0 8px;
border-radius: 6px;
background: var(--muted-bg);
font-size: 11px;
margin-right: 9px;
font-weight: 600;
text-decoration: none;
white-space: nowrap;
}
.viewer-actions a.viewer-native-accent {
color: var(--accent);
}
.viewer-actions a:hover {
background: var(--hover);
}
.diff-layout {
display: inline-flex;
Expand Down Expand Up @@ -1005,7 +1040,6 @@ button:disabled {
}
.viewer-find {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
max-width: 100%;
Expand Down Expand Up @@ -1203,7 +1237,7 @@ button:disabled {
word-break: break-word;
}
.viewer-bottom {
padding: 7px 14px;
padding: 7px 16px;
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--muted);
Expand All @@ -1214,6 +1248,34 @@ button:disabled {
.viewer-bottom .flex {
flex: 1;
}
.viewer-bottom:has(> .file-review) {
flex-wrap: wrap;
}
.file-review {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-width: 0;
margin-left: auto;
}
.file-review[hidden] {
display: none;
}
.file-review .small-button {
margin: 0;
flex-shrink: 0;
}
.file-review-status {
max-width: 36ch;
overflow-wrap: anywhere;
}
.file-review-status:empty {
display: none;
}
.file-review-status.error {
color: var(--red);
}
.file-workspace {
margin: 16px;
background: var(--bg);
Expand Down
1 change: 1 addition & 0 deletions src/content/sidebar/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ export function createRender(app) {
}),
);
if (state.mode === 'changes' && state.diff) {
void state.viewedVersion;
if (state.diff.viewedMode === 'local')
notice.append(document.createTextNode('Viewed marks are local to this browser. '));
for (const warning of state.diff.warnings) notice.append(el('div', {text: warning}));
Expand Down
37 changes: 5 additions & 32 deletions src/content/sidebar/tree.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ import {blobURL, pullURL, repoURL} from '../../shared/routes.js';
import {fileKind, flatten} from '../../shared/tree.js';
import {button, el, highlight} from '../dom.js';
import {fileIconElement, matchIcon} from './file-icons.js';
import {batch, createMemo, createRenderEffect, createSignal, on, onCleanup, withOwner} from '../reactive.js';
import {createMemo, createRenderEffect, createSignal, on, onCleanup, withOwner} from '../reactive.js';
import {requestFrame} from '../reactive-dom.js';
import {createViewedAction} from '../viewed.js';

/** Fixed tree-row height in pixels; virtualization depends on it. */
const rowHeight = 29;
Expand Down Expand Up @@ -155,11 +156,7 @@ export function createTree(app) {
);
row.append(full);
if (state.diff?.viewedMode !== 'none') {
const context = state.context;
const headSHA = state.diff.head.sha;
const epoch = state.epoch;
const generation = state.filesGeneration;
const alive = app.pageAlive || (() => true);
const action = createViewedAction(app);
const checkbox = el('input', {
type: 'checkbox',
class: 'viewed-checkbox',
Expand All @@ -174,33 +171,9 @@ export function createTree(app) {
const viewed = checkbox.checked;
checkbox.disabled = true;
try {
const result = await app.rpc('VIEWED', {context, headSHA, path: node.path, viewed});
if (
!alive() ||
epoch !== state.epoch ||
generation !== state.filesGeneration ||
state.diff?.head.sha !== headSHA
)
return;
batch(() => {
node.viewed = result.state === 'VIEWED';
// Publish file marks for progress/count bindings; pending or failed writes never reach them.
state.entries = state.entries.map(entry =>
entry.path === node.path ? {...entry, viewed: node.viewed} : entry,
);
// Only ancestors can change; search and collapsed rows do not affect the full tree.
let path = node.path;
state.tree.nodes.get(path).viewed = node.viewed;
while (path.includes('/')) {
path = path.slice(0, path.lastIndexOf('/'));
const parent = state.tree.nodes.get(path);
parent.viewed =
parent.loaded && parent.children.length > 0 && parent.children.every(child => child.viewed);
}
updateTree();
});
await action.set(node.path, viewed);
} catch (error) {
checkbox.checked = Boolean(node.viewed);
checkbox.checked = action.viewed(node.path);
throw error;
} finally {
checkbox.disabled = false;
Expand Down
2 changes: 2 additions & 0 deletions src/content/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const viewFields = [
'viewError',
'diff',
'diffLayout',
'viewedVersion',
'pulls',
'loadingAll',
'selected',
Expand Down Expand Up @@ -85,6 +86,7 @@ export function createState() {
viewError: '',
diff: null,
diffLayout: 'unified',
viewedVersion: 0,
branches: null,
pulls: [],
loadingAll: false,
Expand Down
94 changes: 94 additions & 0 deletions src/content/viewed.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/*
* https://github.com/ziqq/Codetree
* Copyright (C) 2026 Anton Ustinoff
* https://github.com/ziqq/Codetree/blob/main/LICENSE
*
* Revision-bound Viewed writes shared by the changed-file tree and file viewers.
*/

import {batch} from './reactive.js';

/** Pending writes reject duplicate updates from controls sharing one loaded diff. */
const pendingPaths = new WeakMap();

/** Captures a loaded review so delayed controls cannot mark a different page or revision. */
export function createViewedAction(app) {
const {state} = app;
const context = state.context;
const diff = state.diff;
const headSHA = diff?.head?.sha;
const baseSHA = diff?.base?.sha;
const epoch = state.epoch;
const generation = state.filesGeneration;
const alive = app.pageAlive || (() => true);

/** Requires the captured page, loaded diff and exact source revisions to remain current. */
function current() {
return Boolean(
alive() &&
context &&
state.context === context &&
state.diff === diff &&
headSHA &&
diff.head?.sha === headSHA &&
diff.base?.sha === baseSHA &&
['github', 'local'].includes(diff.viewedMode) &&
epoch === state.epoch &&
generation === state.filesGeneration,
);
}

/** Reads a confirmed mark and subscribes viewer controls to marks published elsewhere. */
function viewed(path) {
void state.viewedVersion;
return diff?.viewed?.[path] === 'VIEWED';
}

/** Confirms one listed file's mark before publishing it to viewer and sidebar bindings. */
async function set(path, value) {
if (!current()) return false;
if (typeof value !== 'boolean' || !diff.files.some(file => file.filename === path))
throw new Error('This file is not part of the loaded review.');
let pending = pendingPaths.get(diff);
if (!pending) {
pending = new Set();
pendingPaths.set(diff, pending);
}
if (pending.has(path)) throw new Error('This file already has a Viewed update in progress.');
pending.add(path);
try {
const result = await app.rpc('VIEWED', {context, headSHA, path, viewed: value});
if (!current()) return false;
const expected = value ? 'VIEWED' : 'UNVIEWED';
if (result?.state !== expected || (result.mode !== undefined && !['github', 'local'].includes(result.mode)))
throw new Error('The Viewed update was not confirmed. Try again.');
batch(() => {
diff.viewed = {...diff.viewed, [path]: expected};
if (result.mode !== undefined) diff.viewedMode = result.mode;
if (state.mode === 'changes') {
state.entries = state.entries.map(entry => (entry.path === path ? {...entry, viewed: value} : entry));
const node = state.tree.nodes.get(path);
if (node) {
node.viewed = value;
let parentPath = path;
while (parentPath.includes('/')) {
parentPath = parentPath.slice(0, parentPath.lastIndexOf('/'));
const parent = state.tree.nodes.get(parentPath);
if (!parent) break;
parent.viewed =
parent.loaded && parent.children.length > 0 && parent.children.every(child => child.viewed);
}
}
app.updateTree?.();
}
// Keep the diff identity: replacing it would reload the current file and discard its position.
state.viewedVersion++;
});
return true;
} finally {
pending.delete(path);
}
}

return {current, viewed, set};
}
Loading
Loading