diff --git a/Frontend/src/scripts/features/repo/diffView.ts b/Frontend/src/scripts/features/repo/diffView.ts index c22a3af..4b8f086 100644 --- a/Frontend/src/scripts/features/repo/diffView.ts +++ b/Frontend/src/scripts/features/repo/diffView.ts @@ -71,7 +71,9 @@ function buildUntrackedTextPatch(path: string, text: string): string[] { /** Highlights a row in the left list for the current tab. */ export function highlightRow(index: number) { const rows = qsa((prefs.tab === 'history' ? '.row.commit' : '.row'), listEl || (undefined as any)); - rows.forEach((el, i) => el.classList.toggle('active', i === index)); + rows.forEach((el, i) => { + el.classList.toggle('active', i === index); + }); } /** Loads and renders the selected file diff with selection state restored. */ @@ -293,7 +295,9 @@ export function clearDiffSelection() { if (state.diffSelectedFiles && state.diffSelectedFiles.size > 0) { state.diffSelectedFiles.clear(); const rows = listEl.querySelectorAll('li.row.diffsel'); - rows.forEach((r) => r.classList.remove('diffsel')); + rows.forEach((r) => { + r.classList.remove('diffsel'); + }); } } @@ -301,7 +305,9 @@ export function clearDiffSelection() { export function clearActiveRows() { if (!listEl) return; const rows = listEl.querySelectorAll('li.row.active'); - rows.forEach((r) => r.classList.remove('active')); + rows.forEach((r) => { + r.classList.remove('active'); + }); } /** Loads and renders conflict details and resolution actions. */ @@ -331,7 +337,7 @@ async function renderConflictView(file: FileStatus) { function renderConflictMarkup(details: ConflictDetails) { const binary = !!details.binary; const header = `
Merge conflict
${renderConflictActions(binary)}
`; - const body = binary ? renderBinaryConflictBody(details) : renderTextConflictBody(details); + const body = binary ? renderBinaryConflictBody() : renderTextConflictBody(details); const pathAttr = escapeHtml(details.path || ''); return `
${header}${body}
`; } @@ -347,7 +353,7 @@ function renderConflictActions(binary: boolean) { } /** Renders a compact binary-conflict explanation panel. */ -function renderBinaryConflictBody(details: ConflictDetails) { +function renderBinaryConflictBody() { const note = 'This file is binary. Choose which version to keep.'; return `
${escapeHtml(note)}
`; } @@ -438,10 +444,10 @@ export function toggleFilePick(path: string, on: boolean) { (state as any).selectedHunksByFile[state.currentFile] = state.selectedHunks.slice(); const rec: Record = {}; hunkNodes.forEach((refs, idx) => { - if (refs.hunkCheckbox) { - refs.hunkCheckbox.checked = true; - refs.hunkCheckbox.indeterminate = false; - } + refs.hunkCheckboxes.forEach((box) => { + box.checked = true; + box.indeterminate = false; + }); const picked: number[] = []; Object.entries(refs.lineCheckboxes).forEach(([key, box]) => { const lineIdx = Number(key); @@ -457,10 +463,10 @@ export function toggleFilePick(path: string, on: boolean) { delete (state as any).selectedHunksByFile[state.currentFile]; delete (state as any).selectedLinesByFile[state.currentFile]; hunkNodes.forEach((refs) => { - if (refs.hunkCheckbox) { - refs.hunkCheckbox.checked = false; - refs.hunkCheckbox.indeterminate = false; - } + refs.hunkCheckboxes.forEach((box) => { + box.checked = false; + box.indeterminate = false; + }); Object.values(refs.lineCheckboxes).forEach((box) => { box.checked = false; }); }); } @@ -478,11 +484,13 @@ export function updateHunkCheckboxes() { : {}; nodes.forEach((refs, idx) => { const isSelected = state.selectedHunks.includes(idx); - if (refs.hunkCheckbox) { - refs.hunkCheckbox.checked = isSelected; - refs.hunkCheckbox.indeterminate = false; - } - refs.hunkEl.classList.toggle('picked', isSelected); + refs.hunkCheckboxes.forEach((box) => { + box.checked = isSelected; + box.indeterminate = false; + }); + refs.hunkEls.forEach((el) => { + el.classList.toggle('picked', isSelected); + }); if (state.currentFile) { const lines = rec[idx] || []; Object.entries(refs.lineCheckboxes).forEach(([key, box]) => { @@ -490,15 +498,15 @@ export function updateHunkCheckboxes() { const checked = Array.isArray(lines) && lines.includes(lineIdx); box.checked = checked; }); - if (refs.hunkCheckbox) { - const total = state.currentDiffMeta?.changeCounts[idx] ?? Object.keys(refs.lineCheckboxes).length; - const chosen = lines.length; - refs.hunkCheckbox.checked = total > 0 && chosen === total; - refs.hunkCheckbox.indeterminate = chosen > 0 && chosen < total; - } + const total = state.currentDiffMeta?.changeCounts[idx] ?? Object.keys(refs.lineCheckboxes).length; + const chosen = lines.length; + refs.hunkCheckboxes.forEach((box) => { + box.checked = total > 0 && chosen === total; + box.indeterminate = chosen > 0 && chosen < total; + }); } else { Object.values(refs.lineCheckboxes).forEach((box) => { box.checked = false; }); - if (refs.hunkCheckbox) refs.hunkCheckbox.indeterminate = false; + refs.hunkCheckboxes.forEach((box) => { box.indeterminate = false; }); } }); } @@ -550,11 +558,13 @@ function handleHunkToggle(input: HTMLInputElement) { } (state as any).selectedLinesByFile[state.currentFile] = rec; const refs = state.currentDiffHunkNodes.get(idx); - refs?.hunkEl?.classList.toggle('picked', input.checked); - if (refs?.hunkCheckbox) { - refs.hunkCheckbox.indeterminate = false; - refs.hunkCheckbox.checked = input.checked; - } + refs?.hunkEls.forEach((el) => { + el.classList.toggle('picked', input.checked); + }); + refs?.hunkCheckboxes.forEach((box) => { + box.indeterminate = false; + box.checked = input.checked; + }); if (state.currentFile) { (state as any).selectedHunksByFile[state.currentFile] = state.selectedHunks.slice(); } @@ -586,17 +596,20 @@ function handleLineToggle(input: HTMLInputElement) { (state as any).selectedLinesByFile[state.currentFile] = rec; const refs = state.currentDiffHunkNodes.get(hunk); const total = state.currentDiffMeta?.changeCounts[hunk] ?? Object.keys(refs?.lineCheckboxes || {}).length; - const hunkBox = refs?.hunkCheckbox; - if (hunkBox) { - hunkBox.checked = total > 0 && next.length === total; - hunkBox.indeterminate = next.length > 0 && next.length < total; - if (hunkBox.checked) { - if (!state.selectedHunks.includes(hunk)) state.selectedHunks.push(hunk); - } else { - state.selectedHunks = state.selectedHunks.filter((i) => i !== hunk); - } - refs?.hunkEl?.classList.toggle('picked', hunkBox.checked); + const hunkChecked = total > 0 && next.length === total; + const hunkIndeterminate = next.length > 0 && next.length < total; + refs?.hunkCheckboxes.forEach((box) => { + box.checked = hunkChecked; + box.indeterminate = hunkIndeterminate; + }); + if (hunkChecked) { + if (!state.selectedHunks.includes(hunk)) state.selectedHunks.push(hunk); + } else { + state.selectedHunks = state.selectedHunks.filter((i) => i !== hunk); } + refs?.hunkEls.forEach((el) => { + el.classList.toggle('picked', hunkChecked); + }); if (state.currentFile) { (state as any).selectedHunksByFile[state.currentFile] = state.selectedHunks.slice(); } @@ -717,40 +730,22 @@ function buildDiffFragment(lines: string[]): DocumentFragment { const e = meta.starts[h + 1]; const hunkLines = meta.rest.slice(s, e); const offset = meta.offset + s; - const hunkEl = document.createElement('div'); - hunkEl.className = 'hunk'; - hunkEl.dataset.hunkIndex = String(h); - - const header = document.createElement('div'); - header.className = 'hline'; - const gutter = document.createElement('div'); - gutter.className = 'gutter'; - const label = document.createElement('label'); - label.className = 'pick-toggle'; - const hunkCheckbox = document.createElement('input'); - hunkCheckbox.type = 'checkbox'; - hunkCheckbox.className = 'pick-hunk'; - hunkCheckbox.dataset.hunk = String(h); - label.appendChild(hunkCheckbox); - const srHunk = document.createElement('span'); - srHunk.className = 'sr-only'; - srHunk.textContent = 'Include hunk'; - label.appendChild(srHunk); - gutter.appendChild(label); - header.appendChild(gutter); - const codeHeader = document.createElement('div'); - codeHeader.className = 'code'; - header.appendChild(codeHeader); - hunkEl.appendChild(header); - const lineCheckboxes: Record = {}; - hunkLines.forEach((ln, i) => { + const lineRows = hunkLines.map((ln, i) => { const first = (typeof ln === 'string' ? ln[0] : ' ') || ' '; const lineRow = document.createElement('div'); lineRow.className = `hline${first === '+' ? ' add' : first === '-' ? ' del' : ''}`; const lineGutter = document.createElement('div'); lineGutter.className = 'gutter'; if (first === '+' || first === '-') { + const leftNumber = document.createElement('span'); + leftNumber.className = 'line-number line-number-left'; + const rightNumber = document.createElement('span'); + rightNumber.className = 'line-number line-number-right'; + const numberText = String(offset + i + 1); + if (first === '-') leftNumber.textContent = numberText; + else rightNumber.textContent = numberText; + lineGutter.appendChild(leftNumber); const lineLabel = document.createElement('label'); lineLabel.className = 'pick-toggle'; const lineCheckbox = document.createElement('input'); @@ -764,18 +759,65 @@ function buildDiffFragment(lines: string[]): DocumentFragment { srLine.textContent = 'Include line'; lineLabel.appendChild(srLine); lineGutter.appendChild(lineLabel); + lineGutter.appendChild(rightNumber); lineCheckboxes[i] = lineCheckbox; } - lineGutter.appendChild(document.createTextNode(String(offset + i + 1))); + if (first !== '+' && first !== '-') { + const lineNumber = document.createElement('span'); + lineNumber.className = 'line-number line-number-right'; + lineNumber.textContent = String(offset + i + 1); + lineGutter.appendChild(lineNumber); + } const code = document.createElement('div'); code.className = 'code'; code.innerHTML = escapeHtml(String(ln || '')); lineRow.appendChild(lineGutter); lineRow.appendChild(code); - hunkEl.appendChild(lineRow); + return lineRow; }); - nodes.set(h, { hunkEl, hunkCheckbox, lineCheckboxes }); - fragment.appendChild(hunkEl); + const hunkEls: HTMLElement[] = []; + const hunkCheckboxes: HTMLInputElement[] = []; + let currentSegmentRows: HTMLElement[] = []; + const flushSegment = () => { + if (currentSegmentRows.length === 0) return; + const hunkEl = document.createElement('div'); + hunkEl.className = 'hunk'; + hunkEl.dataset.hunkIndex = String(h); + const selectionBody = document.createElement('div'); + selectionBody.className = 'hunk-selection-body'; + const hunkCheckbox = document.createElement('input'); + hunkCheckbox.type = 'checkbox'; + hunkCheckbox.className = 'pick-hunk'; + hunkCheckbox.dataset.hunk = String(h); + const label = document.createElement('label'); + label.className = 'pick-toggle'; + label.appendChild(hunkCheckbox); + const srHunk = document.createElement('span'); + srHunk.className = 'sr-only'; + srHunk.textContent = 'Include hunk'; + label.appendChild(srHunk); + selectionBody.appendChild(label); + currentSegmentRows.forEach((row) => { + selectionBody.appendChild(row); + }); + hunkEl.appendChild(selectionBody); + fragment.appendChild(hunkEl); + hunkEls.push(hunkEl); + hunkCheckboxes.push(hunkCheckbox); + currentSegmentRows = []; + }; + hunkLines.forEach((ln, i) => { + const first = (typeof ln === 'string' ? ln[0] : ' ') || ' '; + const row = lineRows[i]; + if (first === '+' || first === '-') { + currentSegmentRows.push(row); + return; + } + flushSegment(); + fragment.appendChild(row); + }); + flushSegment(); + nodes.set(h, { hunkEls, hunkCheckboxes, lineCheckboxes }); } state.currentDiffHunkNodes = nodes; return fragment; @@ -801,11 +843,30 @@ export function renderHunksWithSelection(lines: string[]) { const body = hunkLines.map((ln, i) => { const first = (typeof ln === 'string' ? ln[0] : ' ') || ' '; const isChange = first === '+' || first === '-'; + const numberText = `${offset + i + 1}`; + const leftNumber = isChange && first === '-' ? `${numberText}` : isChange ? `` : ''; + const rightNumber = isChange && first === '+' ? `${numberText}` : isChange ? `` : `${numberText}`; const lineCheckbox = isChange ? `` : ''; const t = first === '+' ? 'add' : first === '-' ? 'del' : ''; - return `
${lineCheckbox}${offset + i + 1}
${escapeHtml(String(ln))}
`; - }).join(''); - html += `
${body}
`; + return `
${leftNumber}${lineCheckbox}${rightNumber}
${escapeHtml(String(ln))}
`; + }); + let currentSegmentRows: string[] = []; + const flushSegment = () => { + if (currentSegmentRows.length === 0) return; + html += `
${currentSegmentRows.join('')}
`; + currentSegmentRows = []; + }; + hunkLines.forEach((ln, i) => { + const first = (typeof ln === 'string' ? ln[0] : ' ') || ' '; + const row = body[i]; + if (first === '+' || first === '-') { + currentSegmentRows.push(row); + return; + } + flushSegment(); + html += row; + }); + flushSegment(); } return html; } diff --git a/Frontend/src/scripts/state/state.ts b/Frontend/src/scripts/state/state.ts index 399b13b..e9dfdc4 100644 --- a/Frontend/src/scripts/state/state.ts +++ b/Frontend/src/scripts/state/state.ts @@ -32,8 +32,8 @@ export type DiffMeta = { /** References to DOM elements for a hunk. */ export type HunkNodeRefs = { - hunkEl: HTMLElement; - hunkCheckbox: HTMLInputElement | null; + hunkEls: HTMLElement[]; + hunkCheckboxes: HTMLInputElement[]; lineCheckboxes: Record; }; diff --git a/Frontend/src/styles/components.css b/Frontend/src/styles/components.css index 2d29c00..5d1033f 100644 --- a/Frontend/src/styles/components.css +++ b/Frontend/src/styles/components.css @@ -154,11 +154,21 @@ button.saved-state,.btn.saved-state,.tbtn.saved-state{ .row{ display:flex; align-items:center; gap:.6rem; padding:.5rem .6rem; border-radius:var(--r-md); - border:1px solid transparent; cursor:pointer; + border:1px solid color-mix(in oklab, var(--border) 88%, transparent); + background:color-mix(in oklab, var(--surface) 86%, transparent); + cursor:pointer; position:relative; /* allow active marker */ + transition:background-color .15s ease, border-color .15s ease, box-shadow .15s ease; +} +.row:hover{ + background:color-mix(in oklab, var(--text) 4%, var(--surface)); + border-color:color-mix(in oklab, var(--border) 68%, var(--text) 10%); +} +.row.active{ + background:color-mix(in oklab, var(--accent) 14%, var(--surface)); + border-color:color-mix(in oklab, var(--accent) 40%, transparent); + box-shadow:inset 0 0 0 1px color-mix(in oklab, var(--accent) 18%, transparent); } -.row:hover{ background:color-mix(in oklab, var(--text) 6%, transparent); } -.row.active{ background:color-mix(in oklab, var(--accent) 16%, transparent); border-color:color-mix(in oklab, var(--accent) 42%, transparent); } /* Add a thin accent marker for the active (viewed) row so it stays visible even when picked */ .row.active::before{ content:""; position:absolute; left:0; top:0; bottom:0; width:3px; @@ -176,13 +186,25 @@ button.saved-state,.btn.saved-state,.tbtn.saved-state{ border-color:color-mix(in oklab, var(--warning) 54%, transparent); } /* Picked (included in next commit) uses a success tint so it differs from active */ -.row.picked{ background:color-mix(in oklab, var(--success) 12%, transparent); border-color:color-mix(in oklab, var(--success) 42%, transparent); } +.row.picked{ + background:color-mix(in oklab, var(--success) 12%, var(--surface)); + border-color:color-mix(in oklab, var(--success) 42%, transparent); + box-shadow:inset 0 0 0 1px color-mix(in oklab, var(--success) 16%, transparent); +} /* When a row is both picked and active, keep the picked (green) background and show active marker */ -.row.active.picked{ background:color-mix(in oklab, var(--success) 12%, transparent); border-color:color-mix(in oklab, var(--success) 42%, transparent); } +.row.active.picked{ + background:color-mix(in oklab, var(--success) 12%, var(--surface)); + border-color:color-mix(in oklab, var(--success) 42%, transparent); + box-shadow:inset 0 0 0 1px color-mix(in oklab, var(--success) 16%, transparent), inset 3px 0 0 var(--accent); +} /* Diff-selected (included in viewer) uses a subtle accent tint distinct from picked */ -.row.diffsel{ background:color-mix(in oklab, var(--accent) 10%, transparent); border-color:color-mix(in oklab, var(--accent) 32%, transparent); } +.row.diffsel{ + background:color-mix(in oklab, var(--accent) 10%, var(--surface)); + border-color:color-mix(in oklab, var(--accent) 32%, transparent); + box-shadow:inset 0 0 0 1px color-mix(in oklab, var(--accent) 14%, transparent); +} .row.active.diffsel::before{ background:var(--accent); } -.file{ white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } +.file{ flex:1 1 auto; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .status.add, .status-dot.add{ color:var(--success); } .status.untracked, .status-dot.untracked{ color:color-mix(in oklab, var(--success) 70%, var(--muted)); } .status.mod, .status-dot.mod{ color:var(--warning); } @@ -217,7 +239,8 @@ button.saved-state,.btn.saved-state,.tbtn.saved-state{ /* Diff header (fixed) */ .diff-head{ display:flex; align-items:center; justify-content:space-between; gap:.6rem; - padding:.6rem .8rem; border-bottom:1px solid var(--border); + padding:.5rem .85rem; border-bottom:1px solid color-mix(in oklab, var(--border) 52%, transparent); + background:color-mix(in oklab, var(--surface) 92%, var(--surface-2)); flex:0 0 auto; } .diff-head .path{ @@ -261,6 +284,7 @@ button.saved-state,.btn.saved-state,.tbtn.saved-state{ height: 100%; overflow-y: scroll; font-family:var(--mono); font-size:13px; + background:color-mix(in oklab, var(--surface-2) 88%, var(--surface)); overflow-anchor:none; scrollbar-gutter: stable both-edges; box-sizing: border-box; @@ -283,38 +307,189 @@ button.saved-state,.btn.saved-state,.tbtn.saved-state{ .diff .diff-content { padding-right: 0; } -.hunk{ padding:.25rem 0; } -/* Picked hunk (included in commit) — success tint */ -.hunk.picked{ background:color-mix(in oklab, var(--success) 10%, transparent); border-left:3px solid color-mix(in oklab, var(--success) 65%, transparent); } +.hunk{ + padding:0; + position:relative; + border-bottom:1px solid color-mix(in oklab, var(--border) 48%, transparent); +} +.hunk:last-child{ border-bottom:0; } +/* Picked hunk body (included in commit) — accent tint */ +.hunk.picked .hunk-selection-body{ + background:color-mix(in oklab, var(--accent) 8%, transparent); + box-shadow:inset 3px 0 0 color-mix(in oklab, var(--accent) 65%, transparent); +} +.hunk-selection-body{ + position:relative; +} +.hunk-selection-body > .pick-toggle{ + position:absolute; + inset:0 auto 0 0; + width:1.45rem; + min-block-size:100%; + align-items:flex-start; + justify-content:center; + padding-top:.12rem; + z-index:1; +} .hline{ - display:grid; grid-template-columns:56px 1fr; gap:.6rem; - padding:.15rem .8rem; + display:grid; grid-template-columns:minmax(3.75rem,4.7rem) minmax(0,1fr); gap:.6rem; + padding:0 .8rem 0 0; line-height:1.5; +} +.hunk-selection-body > .hline{ + padding-left:1.45rem; +} +.gutter{ + color:color-mix(in oklab, var(--muted) 74%, var(--text) 26%); + display:grid; grid-template-columns:minmax(0,1fr) 1.45rem minmax(0,1fr); align-items:stretch; gap:0; + min-block-size:1.5em; padding-inline-end:.4rem; + text-align:right; user-select:none; font-variant-numeric:tabular-nums; letter-spacing:-.01em; + cursor:pointer; + position:relative; +} +.gutter .pick-toggle{ + position:absolute; + inset:0; + z-index:1; + display:flex; + align-items:center; + justify-content:center; +} +.line-number{ + display:block; + grid-column:3; + justify-self:end; + align-self:center; + padding-inline-end:.4rem; + pointer-events:none; +} +.gutter .pick-toggle{ + grid-column:1 / -1 !important; + align-self:stretch; + z-index:1; + display:flex !important; + width:100% !important; + height:100% !important; +} +.hline.del .line-number{ + grid-column:1; +} +.code{ white-space:pre-wrap; overflow-wrap:anywhere; word-break:break-word; min-width:0; line-height:1.5; } +.hline.add, +.hline.del{ + position:relative; + --diff-row-top-shadow:inset 0 1px 0 transparent; + --diff-row-bottom-shadow:inset 0 -1px 0 transparent; +} +.hline.add{ + background:color-mix(in oklab, var(--success) 10%, transparent); + box-shadow: + inset 1px 0 0 color-mix(in oklab, var(--success) 30%, transparent), + inset -1px 0 0 color-mix(in oklab, var(--success) 30%, transparent), + var(--diff-row-top-shadow), + var(--diff-row-bottom-shadow); +} +.hline.del{ + background:color-mix(in oklab, var(--danger) 10%, transparent); + box-shadow: + inset 1px 0 0 color-mix(in oklab, var(--danger) 30%, transparent), + inset -1px 0 0 color-mix(in oklab, var(--danger) 30%, transparent), + var(--diff-row-top-shadow), + var(--diff-row-bottom-shadow); +} +.hunk-selection-body > .pick-toggle + .hline.add, +.hline:not(.add) + .hline.add{ + --diff-row-top-shadow:inset 0 1px 0 color-mix(in oklab, var(--success) 30%, transparent); + border-top-left-radius:6px; + border-top-right-radius:6px; +} +.hline.add:not(:has(+ .hline.add)){ + --diff-row-bottom-shadow:inset 0 -1px 0 color-mix(in oklab, var(--success) 30%, transparent); + border-bottom-left-radius:6px; + border-bottom-right-radius:6px; +} +.hunk-selection-body > .pick-toggle + .hline.del, +.hline:not(.del) + .hline.del{ + --diff-row-top-shadow:inset 0 1px 0 color-mix(in oklab, var(--danger) 30%, transparent); + border-top-left-radius:6px; + border-top-right-radius:6px; +} +.hline.del:not(:has(+ .hline.del)){ + --diff-row-bottom-shadow:inset 0 -1px 0 color-mix(in oklab, var(--danger) 30%, transparent); + border-bottom-left-radius:6px; + border-bottom-right-radius:6px; +} +.hunk.picked .hunk-selection-body > .hline.add .gutter, +.hunk.picked .hunk-selection-body > .hline.del .gutter{ + background:color-mix(in oklab, var(--accent) 92%, transparent); + color:color-mix(in oklab, white 88%, var(--surface)); } -.gutter{ color:var(--muted); text-align:right; user-select:none; } -.code{ white-space:pre-wrap; overflow-wrap:anywhere; word-break:break-word; min-width:0; } -.hline.add{ background:color-mix(in oklab, var(--success) 12%, transparent); } -.hline.del{ background:color-mix(in oklab, var(--danger) 12%, transparent); } .binary-placeholder{ color:var(--muted); font-style:italic; } -/* Compact selection toggles */ -.row input.pick, .pick-hunk{ appearance:none; -webkit-appearance:none; width:14px; height:14px; - border:1px solid var(--border); border-radius:3px; background:transparent; display:inline-block; } -/* Use accent for focus/hover, success for checked to reinforce inclusion meaning */ -.row input.pick:checked, .pick-hunk:checked{ background:var(--success); border-color:var(--success); box-shadow:0 0 0 2px color-mix(in oklab, var(--success) 18%, transparent); } -.row input.pick:indeterminate{ background:linear-gradient(0deg, var(--success), var(--success)); opacity:.7; } -/* Show partial selection state for hunk checkbox */ -.pick-hunk:indeterminate{ background:linear-gradient(0deg, var(--success), var(--success)); opacity:.7; } - -/* Line selection checkboxes (in gutter) */ +/* GitHub Desktop-style selection gutter: state lives in the gutter, not a box. */ +.row input.pick, +.pick-hunk, .pick-line{ + appearance:none; -webkit-appearance:none; box-sizing:border-box; + inline-size:1rem; block-size:1rem; + background:transparent; + display:inline-grid; place-items:center; + cursor:pointer; vertical-align:middle; +} +.row input.pick{ + border:1px solid var(--border); + border-radius:4px; +} +.pick-hunk, .pick-line{ + border:0; + border-radius:0; +} +.row input.pick:hover{ + border-color:var(--accent); +} +.row input.pick:checked, +.row input.pick:indeterminate{ + background-color:var(--success); + border-color:var(--success); +} +.pick-toggle{ + display:flex; align-items:center; justify-content:center; + inline-size:100%; block-size:100%; padding:0; + border:0; border-radius:0; box-sizing:border-box; + background:transparent; cursor:pointer; +} .pick-line{ - appearance:none; -webkit-appearance:none; width:12px; height:12px; - border:1px solid var(--border); border-radius:2px; background:transparent; display:inline-block; -} -.pick-line:checked{ background:var(--success); border-color:var(--success); box-shadow:0 0 0 2px color-mix(in oklab, var(--success) 18%, transparent); } -.pick-line:hover{ border-color:var(--accent); } -.pick-toggle{ display:inline-flex; align-items:center; justify-content:center; gap:.25rem; } -.row input.pick:hover, .pick-hunk:hover{ border-color:var(--accent); } -.pick-toggle{ display:inline-flex; align-items:center; justify-content:center; } + inline-size:100%; + block-size:100%; +} +.pick-toggle:hover, +.pick-toggle:focus-within{ background:color-mix(in oklab, var(--accent) 28%, transparent); } +.pick-toggle:has(input:checked), +.pick-toggle:has(input:indeterminate){ background:var(--accent); } +.hline:has(.pick-line:checked) .gutter, +.hline:has(.pick-line:indeterminate) .gutter, +.hline:has(.pick-hunk:checked) .gutter, +.hline:has(.pick-hunk:indeterminate) .gutter{ + background:color-mix(in oklab, var(--accent) 92%, transparent); + color:color-mix(in oklab, white 88%, var(--surface)); +} +.row input.pick:focus-visible, .pick-hunk:focus-visible, .pick-line:focus-visible{ + outline:2px solid color-mix(in oklab, white 82%, var(--accent)); + outline-offset:2px; +} +.row input.pick:checked, .pick-hunk:checked, .pick-line:checked{ + background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8.2 6.5 11 12.7 4.8' fill='none' stroke='white' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-position:center; + background-repeat:no-repeat; + background-size:.86rem .86rem; + color:color-mix(in oklab, white 92%, var(--surface)); +} +.row input.pick:indeterminate, .pick-hunk:indeterminate, .pick-line:indeterminate{ + background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4 8h8' fill='none' stroke='white' stroke-width='2.2' stroke-linecap='round'/%3E%3C/svg%3E"); + background-position:center; + background-repeat:no-repeat; + background-size:.86rem .86rem; + color:color-mix(in oklab, white 92%, var(--surface)); +} +.row input.pick:disabled, .pick-hunk:disabled, .pick-line:disabled{ cursor:not-allowed; opacity:.65; } .sr-only{ position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; } /* Context menu */ @@ -343,14 +518,14 @@ button.saved-state,.btn.saved-state,.tbtn.saved-state{ /* Commit panel (pinned at bottom) */ .commit{ - border-top:1px solid var(--border); background:var(--surface); - padding:.6rem .8rem; display:grid; grid-template-columns:1fr auto; - gap:.6rem; align-items:center; flex:0 0 auto; + border-top:1px solid color-mix(in oklab, var(--border) 52%, transparent); background:color-mix(in oklab, var(--surface) 93%, var(--surface-2)); + padding:.5rem .85rem; display:grid; grid-template-columns:1fr auto; + gap:.45rem; align-items:center; flex:0 0 auto; } -.commit .inputs{ display:grid; gap:.4rem; } +.commit .inputs{ display:grid; gap:.3rem; } .commit input,.commit textarea{ width:100%; border:1px solid var(--border); - background:var(--surface-2); color:var(--text); + background:var(--surface); color:var(--text); border-radius:var(--r-sm); padding:.55rem .65rem; } .commit textarea{ min-height:64px; resize:vertical; } diff --git a/docs/Features.md b/docs/Features.md index bb3ac63..222daa2 100644 --- a/docs/Features.md +++ b/docs/Features.md @@ -20,6 +20,7 @@ OpenVCS is plugin-first and VCS-agnostic. Features, themes, UI changes, and VCS Working tree status✅Shows repository changes Per-file diff✅File-level change inspection Commit diff✅Commit-level inspection + Diff selection readability✅Blue gutter stays continuous through each picked hunk, with a full-height hunk rail and bare per-line checkmarks keeping inclusion easy to scan Discard changes✅Working tree cleanup