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
87 changes: 84 additions & 3 deletions Frontend/src/scripts/features/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export function bindCommit() {
const summary = commitSummary?.value.trim() || '';
if (!summary) { commitSummary?.focus(); notify('Summary is required'); return; }
const hunksMap: Record<string, number[]> = (state as any).selectedHunksByFile || {};
const linesMap: Record<string, Record<number, number[]>> = (state as any).selectedLinesByFile || {};
const hasHunks = Object.keys(hunksMap).some(p => Array.isArray(hunksMap[p]) && hunksMap[p].length > 0);
const selectedFiles = state.selectedFiles ? Array.from(state.selectedFiles) : [];
const hasFiles = selectedFiles.length > 0;
Expand All @@ -27,8 +28,11 @@ export function bindCommit() {
setBusy('Committing…');
const description = commitDesc?.value || '';

// Build a combined patch when any file has partial hunks selected
const partialFiles = Object.keys(hunksMap).filter(p => Array.isArray(hunksMap[p]) && hunksMap[p].length > 0);
// Build a combined patch when any file has partial hunks/lines selected
const partialFiles = Array.from(new Set([
...Object.keys(hunksMap).filter(p => Array.isArray(hunksMap[p]) && hunksMap[p].length > 0),
...Object.keys(linesMap).filter(p => linesMap[p] && Object.keys(linesMap[p] || {}).length > 0),
]));

// Guard: libgit2 backend does not support partial-hunk commit (stage_patch)
if (TAURI.has && partialFiles.length > 0) {
Expand All @@ -49,7 +53,9 @@ export function bindCommit() {
let lines: string[] = [];
try { lines = await TAURI.invoke<string[]>('git_diff_file', { path }); } catch {}
if (!Array.isArray(lines) || lines.length === 0) continue;
combinedPatch += buildPatchForSelectedHunks(path, lines, hunksMap[path]) + '\n';
const selHunks = hunksMap[path] || [];
const selLines = linesMap[path] || {};
combinedPatch += buildPatchForSelected(path, lines, selHunks, selLines) + '\n';
}
// Full files: commit-selected files without partial hunks
const fullFiles = selectedFiles.filter(f => !partialFiles.includes(f));
Expand Down Expand Up @@ -121,3 +127,78 @@ function buildPatchForSelectedHunks(path: string, lines: string[], hunkIndices:
}
return out.trimEnd() + '\n';
}

// Build a patch combining whole selected hunks and per-line selections (unidiff-zero mini-hunks).
function buildPatchForSelected(path: string, lines: string[], hunkIndices: number[] = [], selLines: Record<number, number[]> = {}): string {
const normPath = String(path).replace(/\\/g, '/');
const firstHunk = lines.findIndex(l => (l || '').startsWith('@@'));
const prelude = firstHunk >= 0 ? lines.slice(0, firstHunk) : [];
const rest = firstHunk >= 0 ? lines.slice(firstHunk) : [];

let starts: number[] = [];
for (let i = 0; i < rest.length; i++) { if ((rest[i] || '').startsWith('@@')) starts.push(i); }
if (starts.length === 0) return '';
starts.push(rest.length);

const isAdd = prelude.some(l => l.startsWith('--- /dev/null'));
const isDel = prelude.some(l => l.startsWith('+++ /dev/null'));

let out = `diff --git a/${normPath} b/${normPath}\n`;
if (isAdd) out += `--- /dev/null\n+++ b/${normPath}\n`;
else if (isDel) out += `--- a/${normPath}\n+++ /dev/null\n`;
else out += `--- a/${normPath}\n+++ b/${normPath}\n`;

const wantWhole = new Set<number>((hunkIndices || []).filter((n) => Number.isFinite(n)));
for (let h = 0; h < starts.length - 1; h++) {
const s = starts[h];
const e = starts[h+1];
const block = rest.slice(s, e);
const header = block[0] || '';
const m = /@@\s*-([0-9]+),?([0-9]*)\s*\+([0-9]+),?([0-9]*)\s*@@/.exec(header) || [] as any;
const aStart = parseInt(m[1] || '0', 10) || 0;
const cStart = parseInt(m[3] || '0', 10) || 0;
const content = block.slice(1);

if (wantWhole.has(h)) {
out += header + '\n' + content.join('\n') + '\n';
continue;
}
const picksRaw = (selLines && Array.isArray(selLines[h])) ? selLines[h] : (selLines && selLines[h] ? selLines[h] : []);
// Adjust indices: UI stores data-line relative to the full block (including header at 0)
const picksAdj = Array.isArray(picksRaw) ? picksRaw.map((i) => i - 1).filter((i) => i >= 0 && i < content.length) : [];
const pickSet = new Set<number>(picksAdj || []);
if (pickSet.size === 0) continue;

// prefix counts to compute old/new positions
const prefOld: number[] = new Array(content.length + 1).fill(0);
const prefNew: number[] = new Array(content.length + 1).fill(0);
for (let i = 0; i < content.length; i++) {
const ch = (content[i] || '')[0] || ' ';
prefOld[i+1] = prefOld[i] + (ch === '+' ? 0 : 1); // old advances on ' ' or '-'
prefNew[i+1] = prefNew[i] + (ch === '-' ? 0 : 1); // new advances on ' ' or '+'
}

// group consecutive selected lines into mini-hunks
const sorted = Array.from(pickSet).sort((x,y)=>x-y);
let group: number[] = [];
const flush = () => {
if (group.length === 0) return;
const i0 = group[0];
const old_start = aStart + prefOld[i0];
const new_start = cStart + prefNew[i0];
const slice = group.map(i => content[i]);
const old_count = slice.filter(l => (l||'')[0] === '-').length;
const new_count = slice.filter(l => (l||'')[0] === '+').length;
out += `@@ -${old_start},${old_count} +${new_start},${new_count} @@\n`;
out += slice.join('\n') + '\n';
group = [];
};
for (let i = 0; i < sorted.length; i++) {
if (group.length === 0) { group.push(sorted[i]); continue; }
if (sorted[i] === group[group.length - 1] + 1) group.push(sorted[i]);
else { flush(); group.push(sorted[i]); }
}
flush();
}
return out.trimEnd() + '\n';
}
161 changes: 152 additions & 9 deletions Frontend/src/scripts/features/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,8 +262,25 @@ async function selectFile(file: { path: string }, index: number) {
state.selectedHunks = cached.slice();
updateHunkCheckboxes();
} else if (state.selectedFiles.has(file.path) || state.defaultSelectAll) {
// Default-select all hunks and all changed lines for the current file
state.selectedHunks = allHunkIndices(state.currentDiff);
updateHunkCheckboxes();
// Seed per-line selections if not already present
const recExisting: Record<number, number[]> = (state as any).selectedLinesByFile[state.currentFile] || {};
const root = diffEl as HTMLElement;
const rec: Record<number, number[]> = { ...recExisting };
state.selectedHunks.forEach((h) => {
if (rec[h] && rec[h].length > 0) return;
const boxes = root.querySelectorAll<HTMLInputElement>(`input.pick-line[data-hunk="${h}"]`);
const picked: number[] = [];
boxes.forEach(b => {
b.checked = true;
picked.push(Number(b.dataset.line || -1));
});
if (picked.length > 0) rec[h] = Array.from(new Set(picked)).sort((a,b)=>a-b);
});
(state as any).selectedLinesByFile[state.currentFile] = rec;
updateHunkCheckboxes();
} else {
state.selectedHunks = [];
}
Expand Down Expand Up @@ -434,9 +451,16 @@ function renderHunksWithSelection(lines: string[]) {
const e = starts[h+1];
const hunkLines = rest.slice(s, e);
const offset = (idx >= 0 ? idx : 0) + s; // approximate numbering
const body = hunkLines.map((ln, i) => {
const first = (typeof ln === 'string' ? ln[0] : ' ') || ' ';
const isChange = first === '+' || first === '-';
const lineCheckbox = isChange ? `<label class="pick-toggle"><input type="checkbox" class="pick-line" data-hunk="${h}" data-line="${i}" /><span class="sr-only">Include line</span></label>` : '';
const t = first === '+' ? 'add' : first === '-' ? 'del' : '';
return `<div class="hline ${t}"><div class="gutter">${lineCheckbox}${offset + i + 1}</div><div class="code">${escapeHtml(String(ln))}</div></div>`;
}).join('');
html += `<div class="hunk" data-hunk-index="${h}">
<div class="hline"><div class="gutter"><label class="pick-toggle"><input type="checkbox" class="pick-hunk" data-hunk="${h}" /><span class="sr-only">Include hunk</span></label></div><div class="code"></div></div>
${hunkLines.map((ln, i) => hline(ln, offset + i + 1)).join('')}
${body}
</div>`;
}
return html;
Expand Down Expand Up @@ -700,11 +724,31 @@ function toggleFilePick(path: string, on: boolean) {
// If this is the currently viewed file, mirror selection to all hunks in the diff
if (state.currentFile && state.currentFile === path) {
if (on) {
// Select all hunks
state.selectedHunks = allHunkIndices(state.currentDiff);
(state as any).selectedHunksByFile[state.currentFile] = state.selectedHunks.slice();
// Select all changed lines per hunk
const root = diffEl as HTMLElement;
const rec: Record<number, number[]> = {};
const lineBoxes = root.querySelectorAll<HTMLInputElement>('input.pick-line');
lineBoxes.forEach(b => {
const h = Number(b.dataset.hunk || -1);
const l = Number(b.dataset.line || -1);
if (h < 0 || l < 0) return;
(rec[h] ||= []).push(l);
b.checked = true;
});
// Normalize and assign
Object.keys(rec).forEach(k => { rec[Number(k)] = Array.from(new Set(rec[Number(k)])).sort((a,b)=>a-b); });
(state as any).selectedLinesByFile[state.currentFile] = rec;
} else {
state.selectedHunks = [];
delete (state as any).selectedHunksByFile[state.currentFile];
// Clear all line selections
const root = diffEl as HTMLElement;
const lineBoxes = root.querySelectorAll<HTMLInputElement>('input.pick-line');
lineBoxes.forEach(b => { b.checked = false; });
delete (state as any).selectedLinesByFile[state.currentFile];
}
updateHunkCheckboxes();
}
Expand Down Expand Up @@ -756,6 +800,18 @@ function bindHunkToggles(root: HTMLElement) {
}
if (state.currentFile) {
(state as any).selectedHunksByFile[state.currentFile] = state.selectedHunks.slice();
// Toggle all line checkboxes in this hunk accordingly
const linesInHunk = Array.from(root.querySelectorAll<HTMLInputElement>(`input.pick-line[data-hunk="${idx}"]`));
const rec: Record<number, number[]> = (state as any).selectedLinesByFile[state.currentFile] || {};
if (b.checked) {
const picked: number[] = [];
linesInHunk.forEach((el) => { el.checked = true; picked.push(Number(el.dataset.line || -1)); });
rec[idx] = Array.from(new Set([...(rec[idx] || []), ...picked])).sort((a,b)=>a-b);
} else {
linesInHunk.forEach((el) => { el.checked = false; });
delete rec[idx];
}
(state as any).selectedLinesByFile[state.currentFile] = rec;
}
// Update file checkbox and selectedFiles based on hunk selection
const before = (state.selectedHunks || []).length;
Expand All @@ -771,23 +827,96 @@ function bindHunkToggles(root: HTMLElement) {
if (hk) hk.classList.toggle('picked', b.checked);
});
});

// Per-line toggles
const lineBoxes = root.querySelectorAll<HTMLInputElement>('input.pick-line');
lineBoxes.forEach(b => {
b.addEventListener('change', () => {
state.defaultSelectAll = false;
const hunk = Number(b.dataset.hunk || -1);
const line = Number(b.dataset.line || -1);
if (!state.currentFile || hunk < 0 || line < 0) return;
const rec: Record<number, number[]> = (state as any).selectedLinesByFile[state.currentFile] || {};
const cur = new Set<number>(rec[hunk] || []);
if (b.checked) cur.add(line); else cur.delete(line);
rec[hunk] = Array.from(cur).sort((a,b)=>a-b);
if (rec[hunk].length === 0) delete rec[hunk];
(state as any).selectedLinesByFile[state.currentFile] = rec;

// update hunk checkbox state
const hunkBox = root.querySelector<HTMLInputElement>(`input.pick-hunk[data-hunk="${hunk}"]`);
if (hunkBox) {
const total = root.querySelectorAll<HTMLInputElement>(`input.pick-line[data-hunk="${hunk}"]`).length;
const sel = rec[hunk]?.length || 0;
(hunkBox as any).indeterminate = sel > 0 && sel < total;
hunkBox.checked = sel === total && total > 0;
// Reflect fully-selected lines into selectedHunks for consistency
const idx = Number(hunkBox.dataset.hunk || -1);
if (sel === total && total > 0) {
if (!state.selectedHunks.includes(idx)) state.selectedHunks.push(idx);
} else {
state.selectedHunks = state.selectedHunks.filter(i => i !== idx);
}
// Persist hunk selection snapshot
if (state.currentFile) {
(state as any).selectedHunksByFile[state.currentFile] = state.selectedHunks.slice();
}
}
// Update file row checkbox tri-state
syncFileCheckboxWithHunks();
updateSelectAllState(getVisibleFiles());
updateCommitButton();
});
});
}

function syncFileCheckboxWithHunks() {
if (!state.currentFile) return;
const total = allHunkIndices(state.currentDiff).length;
const sel = (state.selectedHunks || []).length;
if (total === 0) {
const totalHunks = allHunkIndices(state.currentDiff).length;
const selHunks = (state.selectedHunks || []).length;

if (totalHunks === 0) {
updateListCheckboxForPath(state.currentFile, false, false);
state.selectedFiles.delete(state.currentFile);
return;
}
if (sel === 0) {
updateListCheckboxForPath(state.currentFile, false, false);
state.selectedFiles.delete(state.currentFile);
} else if (sel === total) {

// Consider per-line partial selections for this file
const rec: Record<number, number[]> = (state as any).selectedLinesByFile[state.currentFile] || {};
// Build per-hunk change-line counts from current diff
const lines = state.currentDiff || [];
const first = lines.findIndex(l => (l || '').startsWith('@@'));
const rest = first >= 0 ? lines.slice(first) : [];
const starts: number[] = [];
for (let i = 0; i < rest.length; i++) { if ((rest[i] || '').startsWith('@@')) starts.push(i); }
starts.push(rest.length);
const changeCounts: number[] = [];
for (let h = 0; h < Math.max(0, starts.length - 1); h++) {
const s = starts[h];
const e = starts[h + 1];
const block = rest.slice(s + 1, e); // skip header
const cnt = block.reduce((acc, ln) => {
const ch = (ln || '')[0] || ' ';
return acc + ((ch === '+' || ch === '-') ? 1 : 0);
}, 0);
changeCounts[h] = cnt;
}

const hasAnyLineSel = Object.keys(rec).length > 0;
const hasPartialLineSel = Object.keys(rec).some(k => {
const h = Number(k);
const chosen = Array.isArray(rec[h]) ? rec[h].length : 0;
const total = changeCounts[h] || 0;
return chosen > 0 && chosen < total;
});

// Determine file checkbox state
if (selHunks === totalHunks && !hasPartialLineSel) {
updateListCheckboxForPath(state.currentFile, true, false);
state.selectedFiles.add(state.currentFile);
} else if (selHunks === 0 && !hasAnyLineSel) {
updateListCheckboxForPath(state.currentFile, false, false);
state.selectedFiles.delete(state.currentFile);
} else {
updateListCheckboxForPath(state.currentFile, false, true);
state.selectedFiles.delete(state.currentFile);
Expand Down Expand Up @@ -815,6 +944,18 @@ function updateHunkCheckboxes() {
const hk = b.closest('.hunk') as HTMLElement | null;
if (hk) hk.classList.toggle('picked', on);
});

// Sync line checkboxes from state
if (state.currentFile) {
const rec: Record<number, number[]> = (state as any).selectedLinesByFile[state.currentFile] || {};
const lboxes = root.querySelectorAll<HTMLInputElement>('input.pick-line');
lboxes.forEach(b => {
const h = Number(b.dataset.hunk || -1);
const l = Number(b.dataset.line || -1);
const on = Array.isArray(rec[h]) && rec[h].includes(l);
b.checked = on;
});
}
}

function updateListCheckboxForPath(path: string, checked: boolean, indeterminate: boolean) {
Expand Down Expand Up @@ -862,8 +1003,10 @@ function updateCommitButton() {
const summaryFilled = (summary?.value.trim().length ?? 0) > 0;
const hunksSelected = Object.keys((state as any).selectedHunksByFile || {})
.some((k) => Array.isArray((state as any).selectedHunksByFile[k]) && (state as any).selectedHunksByFile[k].length > 0);
const linesSelected = Object.keys((state as any).selectedLinesByFile || {})
.some((k) => !!(state as any).selectedLinesByFile[k] && Object.keys((state as any).selectedLinesByFile[k] || {}).length > 0);
const filesSelected = !!(state.selectedFiles && state.selectedFiles.size > 0);
btn.disabled = !(summaryFilled && (hunksSelected || filesSelected));
btn.disabled = !(summaryFilled && (hunksSelected || linesSelected || filesSelected));
}

// Convert an ISO/RFC3339 datetime string into a short relative phrase.
Expand Down
1 change: 1 addition & 0 deletions Frontend/src/scripts/state/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const state = {
currentDiff: [] as string[],
selectedHunks: [] as number[], // indices of selected hunks for current file
selectedHunksByFile: {} as Record<string, number[]>,
selectedLinesByFile: {} as Record<string, Record<number, number[]>>, // file -> hunkIdx -> line indices
diffSelectedFiles: new Set<string>(), // files included in multi-file diff viewer
// Optional: track the current repo path if you want to show it anywhere
// repoPath: '' as string,
Expand Down
Loading
Loading