Skip to content
Open
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
184 changes: 166 additions & 18 deletions public/project-git-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,39 @@

const projectGitTabState = new Map();

// 'list' or 'tree'; one preference for every project, like DIFF_MODE_KEY.
const GIT_CHANGES_VIEW_KEY = 'gitChangesView';

function gitChangesViewMode() {
let stored = null;
try { stored = localStorage.getItem(GIT_CHANGES_VIEW_KEY); } catch {}
return stored === 'tree' ? 'tree' : 'list';
}

function setGitChangesViewMode(mode) {
try { localStorage.setItem(GIT_CHANGES_VIEW_KEY, mode); } catch {}
}

function gitTabState(projectId) {
if (!projectGitTabState.has(projectId)) {
projectGitTabState.set(projectId, {
repositories: null,
selectedRepo: null,
selectedFiles: new Map(),
diffs: new Map(),
collapsedFolders: new Map(), // repo path -> Set of collapsed tree folder keys
request: 0,
error: '',
});
}
return projectGitTabState.get(projectId);
}

function gitCollapsedSet(state, repoPath) {
if (!state.collapsedFolders.has(repoPath)) state.collapsedFolders.set(repoPath, new Set());
return state.collapsedFolders.get(repoPath);
}

function gitRepoName(repo) {
return pathBasename(repo.path) || repo.path;
}
Expand Down Expand Up @@ -136,7 +155,116 @@ function paintGitDiff(project, state, repo, body, change) {
: `<div class="git-diff-title mono">${escapeHtml(change.path)}</div><div class="git-diff-empty">No textual diff is available.</div>`;
}

function paintGitChanges(project, state, repo, body) {
function gitNaturalCompare(a, b) {
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
}

// Folders before files; a folder whose only child is a folder is merged into
// one row ("src/app"), like VS Code's compact folders.
function gitSortTreeLevel(nodes) {
const sorted = nodes.map((node) => {
if (node.kind !== 'folder') return node;
let name = node.name;
let children = gitSortTreeLevel(node.children);
while (children.length === 1 && children[0].kind === 'folder') {
name = `${name}/${children[0].name}`;
children = children[0].children;
}
return { kind: 'folder', name, children };
});
sorted.sort((a, b) => {
if (a.kind !== b.kind) return a.kind === 'folder' ? -1 : 1;
return gitNaturalCompare(a.name, b.name);
});
return sorted;
}

// Git reports paths with '/' on every platform; renames sit at their new path.
function buildGitChangeTree(changes) {
const root = new Map();
for (const change of changes) {
const parts = String(change.path || '').split('/').filter(Boolean);
let level = root;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
let node = level.get(part);
if (!node || node.kind !== 'folder') {
node = { kind: 'folder', name: part, children: new Map() };
level.set(part, node);
}
level = node.children;
}
const fileName = parts[parts.length - 1] || change.path;
level.set(`\0${fileName}`, { kind: 'file', name: fileName, change });
}
const toArray = (map) => [...map.values()].map(node => node.kind === 'folder' ? { ...node, children: toArray(node.children) } : node);
return gitSortTreeLevel(toArray(root));
}

function gitTreeFileCount(node) {
let count = 0;
for (const child of node.children) count += child.kind === 'file' ? 1 : gitTreeFileCount(child);
return count;
}

// Visible rows in display order; collapsed folders keep their own row only.
function gitTreeRows(tree, collapsedSet, keyPrefix, depth = 0) {
const rows = [];
for (const node of tree) {
const key = keyPrefix ? `${keyPrefix}/${node.name}` : node.name;
if (node.kind === 'folder') {
const collapsed = collapsedSet.has(key);
rows.push({ kind: 'folder', depth, name: node.name, key, count: gitTreeFileCount(node), collapsed });
if (!collapsed) rows.push(...gitTreeRows(node.children, collapsedSet, key, depth + 1));
} else {
rows.push({ kind: 'file', depth, name: node.name, key, change: node.change });
}
}
return rows;
}

function createGitFileRow(project, state, repo, body, list, change, isSelected, opts) {
const tree = !!(opts && opts.tree);
const row = document.createElement('button');
row.type = 'button';
row.className = 'git-file-row' + (tree ? ' tree' : '') + (isSelected ? ' selected' : '');
if (tree) row.style.setProperty('--depth', String(opts.depth || 0));
const renamed = change.oldPath ? `${change.oldPath} → ${change.path}` : change.path;
row.title = tree ? `${renamed}\n${gitChangeMeta(change)}` : renamed;
row.innerHTML = tree
? `<span class="git-file-code ${escapeHtml(change.status)}">${gitChangeCode(change)}</span><span class="git-file-path mono">${escapeHtml(pathBasename(change.path) || change.path)}</span>`
: `<span class="git-file-code ${escapeHtml(change.status)}">${gitChangeCode(change)}</span>
<span class="git-file-text"><span class="git-file-path mono">${escapeHtml(change.path)}</span><span class="git-file-meta">${escapeHtml(gitChangeMeta(change))}</span></span>`;
row.onclick = () => {
state.selectedFiles.set(repo.path, change.path);
list.querySelectorAll('.git-file-row').forEach(el => el.classList.toggle('selected', el === row));
paintGitDiff(project, state, repo, body, change);
};
return row;
}

function createGitFolderRow(project, state, repo, body, collapsedSet, row) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'git-folder-row';
button.dataset.key = row.key;
button.style.setProperty('--depth', String(row.depth));
button.setAttribute('aria-expanded', row.collapsed ? 'false' : 'true');
button.innerHTML = `
<span class="git-folder-chevron">${row.collapsed ? PICONS.chevronRight(11) : PICONS.chevronDown(11)}</span>
<span class="git-folder-icon">${PICONS.folder(13)}</span>
<span class="git-folder-name mono">${escapeHtml(row.name)}</span>
${row.count ? `<span class="git-folder-count">${row.count}</span>` : ''}`;
button.onclick = () => {
if (collapsedSet.has(row.key)) collapsedSet.delete(row.key); else collapsedSet.add(row.key);
paintGitChanges(project, state, repo, body, { repaintDiff: false });
// The list was rebuilt, so put keyboard focus back on the new copy of this row.
[...body.querySelectorAll('.git-folder-row')].find(el => el.dataset.key === row.key)?.focus();
};
return button;
}

function paintGitChanges(project, state, repo, body, { repaintDiff = true } = {}) {
const list = body.querySelector('#git-file-list');
if (!list) return;
const groupOrder = ['conflicted', 'modified', 'added', 'deleted', 'renamed', 'untracked'];
Expand All @@ -148,6 +276,9 @@ function paintGitChanges(project, state, repo, body) {
let selected = repo.changes.find(change => change.path === selectedPath) || repo.changes[0] || null;
if (selected) state.selectedFiles.set(repo.path, selected.path);

const tree = gitChangesViewMode() === 'tree';
const collapsedSet = gitCollapsedSet(state, repo.path);

list.replaceChildren();
for (const kind of groupOrder) {
const changes = repo.changes.filter(change => change.status === kind);
Expand All @@ -156,24 +287,27 @@ function paintGitChanges(project, state, repo, body) {
label.className = 'git-file-group';
label.textContent = groupLabels[kind];
list.appendChild(label);
for (const change of changes) {
const row = document.createElement('button');
row.type = 'button';
row.className = 'git-file-row' + (selected?.path === change.path ? ' selected' : '');
row.title = change.oldPath ? `${change.oldPath} → ${change.path}` : change.path;
row.innerHTML = `
<span class="git-file-code ${escapeHtml(change.status)}">${gitChangeCode(change)}</span>
<span class="git-file-text"><span class="git-file-path mono">${escapeHtml(change.path)}</span><span class="git-file-meta">${escapeHtml(gitChangeMeta(change))}</span></span>`;
row.onclick = () => {
selected = change;
state.selectedFiles.set(repo.path, change.path);
list.querySelectorAll('.git-file-row').forEach(el => el.classList.toggle('selected', el === row));
paintGitDiff(project, state, repo, body, change);
};
list.appendChild(row);

if (!tree) {
for (const change of changes) {
list.appendChild(createGitFileRow(project, state, repo, body, list, change, selected?.path === change.path, { tree: false }));
}
continue;
}
for (const row of gitTreeRows(buildGitChangeTree(changes), collapsedSet, kind)) {
if (row.kind === 'folder') list.appendChild(createGitFolderRow(project, state, repo, body, collapsedSet, row));
else list.appendChild(createGitFileRow(project, state, repo, body, list, row.change, selected?.path === row.change.path, { tree: true, depth: row.depth }));
}
}
paintGitDiff(project, state, repo, body, selected);
if (repaintDiff) paintGitDiff(project, state, repo, body, selected);
}

function gitViewToggleHtml(mode) {
const btn = (view, label, icon) => `<button type="button" class="git-view-btn${mode === view ? ' on' : ''}" data-view="${view}" title="${label}" aria-label="${label}" aria-pressed="${mode === view}">${icon}</button>`;
return `<span class="git-view-toggle" role="group" aria-label="Changes view">
${btn('list', 'View as list', PICONS.list(13))}
${btn('tree', 'View as tree', PICONS.tree(13))}
</span>`;
}

function gitCommitsHtml(repo) {
Expand Down Expand Up @@ -216,7 +350,7 @@ function paintGitRepository(project, state, body) {
${picker}
${gitOverviewHtml(repo)}
<section class="git-section">
<div class="git-section-heading"><span>Changes</span><span class="git-section-meta">${changed ? `${changed} file${changed === 1 ? '' : 's'}` : 'Working tree clean'}</span></div>
<div class="git-section-heading"><span>Changes</span><span class="git-heading-right">${changed ? gitViewToggleHtml(gitChangesViewMode()) : ''}<span class="git-section-meta">${changed ? `${changed} file${changed === 1 ? '' : 's'}` : 'Working tree clean'}</span></span></div>
${changed ? '<div class="git-changes"><div class="git-file-list" id="git-file-list"></div><div class="git-diff-pane" id="git-diff-pane"></div></div>' : '<div class="git-empty-row">There are no staged, unstaged, or untracked files.</div>'}
</section>
<section class="git-section">
Expand All @@ -236,6 +370,20 @@ function paintGitRepository(project, state, body) {
paintGitRepository(project, state, body);
};
});

body.querySelectorAll('.git-view-btn').forEach(button => {
button.onclick = () => {
setGitChangesViewMode(button.dataset.view);
// Read back what was stored so the buttons match what gets rendered.
const effective = gitChangesViewMode();
body.querySelectorAll('.git-view-btn').forEach(btn => {
const on = btn.dataset.view === effective;
btn.classList.toggle('on', on);
btn.setAttribute('aria-pressed', String(on));
});
paintGitChanges(project, state, repo, body, { repaintDiff: false });
};
});
}

function renderProjectGitTab(project, body) {
Expand Down
1 change: 1 addition & 0 deletions public/projects-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ const PICONS = {
// The app's own archive glyph, so this view matches the Sessions tab.
archive: (s = 14) => ICONS.archive(s),
list: (s = 14) => `<svg width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16"/><path d="M4 12h10"/><path d="M4 18h7"/></svg>`,
tree: (s = 14) => `<svg width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12h-8"/><path d="M21 6H8"/><path d="M21 18h-8"/><path d="M3 6v4c0 1.1.9 2 2 2h3"/><path d="M3 10v6c0 1.1.9 2 2 2h3"/></svg>`,
clock: (s = 14) => `<svg width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2 2"/></svg>`,
terminal: (s = 14) => `<svg width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m4 17 6-6-6-6"/><path d="M12 19h8"/></svg>`,
search: (s = 13) => `<svg width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>`,
Expand Down
55 changes: 55 additions & 0 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -5520,6 +5520,32 @@ body { display: flex; flex-direction: column; }
text-transform: none;
}

.git-heading-right { display: flex; align-items: center; gap: 10px; }

/* List/tree switch for the Changes section, styled like .pane-seg. */
.git-view-toggle {
display: inline-flex;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 6px;
overflow: hidden;
}

.git-view-btn {
width: 22px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
background: none;
color: #7a7a90;
cursor: pointer;
}

.git-view-btn:hover { color: #b0b0c4; }
.git-view-btn.on { background: rgba(120,130,255,0.16); color: #a6abff; }

.git-changes {
display: grid;
grid-template-columns: minmax(230px, 34%) minmax(0, 1fr);
Expand Down Expand Up @@ -5558,6 +5584,35 @@ body { display: flex; flex-direction: column; }
.git-file-row:hover { background: rgba(255,255,255,0.035); }
.git-file-row.selected { background: rgba(122,132,255,0.11); }

/* Tree mode: compact single-line file rows and folder rows, both indented by depth. */
.git-file-row.tree {
align-items: center;
padding: 5px 10px 5px calc(10px + var(--depth, 0) * 14px);
}

.git-file-row.tree .git-file-path { flex: 1; min-width: 0; }

.git-folder-row {
width: 100%;
display: flex;
align-items: center;
gap: 6px;
border: 0;
padding: 6px 10px 6px calc(10px + var(--depth, 0) * 14px);
background: transparent;
color: #9a9ab0;
font-family: inherit;
font-size: 10.5px;
text-align: left;
cursor: pointer;
}

.git-folder-row:hover { background: rgba(255,255,255,0.035); }
.git-folder-chevron { display: inline-flex; flex-shrink: 0; color: #6f6f84; }
.git-folder-icon { display: inline-flex; flex-shrink: 0; color: #7d8dc9; }
.git-folder-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.git-folder-count { flex-shrink: 0; color: #5c5c70; font-size: 9.5px; }

.git-file-code {
width: 17px;
flex: 0 0 17px;
Expand Down
Loading