File size: 8,061 Bytes
e7c75bb 0d68e9e e7c75bb 0d68e9e e7c75bb 3a32466 e7c75bb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 | import { createTwoFilesPatch } from 'diff';
const $ = selector => document.querySelector(selector);
const text = value => {
if (value === null) return '';
const decoded = new TextDecoder('utf-8', { fatal: true }).decode(Uint8Array.from(atob(value), c => c.charCodeAt(0)));
if (decoded.includes('\0')) throw Error('Binary file');
return decoded;
};
export function createLocalFolderUI({ rpc, notice, canSwitch, onSwitch }) {
let handle, saved, current, busy = false, opening = false, reviewPatches = [];
const dialog = $('#local-review');
const embedded = window.self !== window.top;
const supported = typeof window.showDirectoryPicker === 'function';
function controls() {
$('#open-folder').hidden = Boolean(current);
$('#reconnect-folder').hidden = Boolean(current) || !saved;
$('#local-folder-bar').hidden = !current;
for (const id of ['open-folder', 'reconnect-folder', 'review-changes', 'refresh-folder', 'disconnect-folder']) $('#' + id).disabled = busy || opening;
$('#review-changes').disabled ||= !current?.count;
$('#refresh-folder').disabled ||= Boolean(current?.count);
$('#review-apply').disabled = busy || !reviewPatches.length;
$('#review-discard').disabled = busy || !reviewPatches.length;
$('#review-close').disabled = busy;
if (current) {
$('#local-folder-name').textContent = current.name;
$('#local-folder-hint').textContent = current.count ? `${current.count} staged change${current.count === 1 ? '' : 's'} · original files unchanged until Apply` : 'Local folder · edits are staged until you apply them';
$('#review-changes').textContent = current.count ? `Review & apply (${current.count})` : 'Review & apply';
}
$('#export').textContent = current ? 'Export changes' : 'Export';
$('#save').textContent = current ? 'Stage edit' : 'Save';
}
function available() {
if (embedded) {
$('#open-tab').href = new URL('?local-folder', location.href).href; $('#open-tab').hidden = false;
notice('Open the workspace in a new tab, then choose Link a folder. Browsers cannot grant folder access inside this embedded Space.');
return false;
}
if (!supported) {
notice('Direct local-folder access needs a supported browser, such as Chrome or Edge. In this browser, use Import and Export to work with copies of your files.');
return false;
}
return true;
}
async function connect(restore) {
if (!available() || busy || opening) return;
try {
canSwitch(); opening = true; controls();
// Invoke the picker/permission request directly from the click, before
// waiting for the worker: these APIs require transient user activation.
let next;
if (restore) {
next = saved.handle;
if (await next.requestPermission({ mode: 'read' }) !== 'granted') throw Error('Folder access was not granted.');
} else {
if (saved?.count && !window.confirm('Opening another folder replaces the saved staged edits for the previous folder. Reconnect to review them first, or continue?')) return;
next = await window.showDirectoryPicker({ id: 'pi-workspace', mode: 'read' });
}
const state = await rpc('local_open', { handle: next, restore });
handle = next; saved = { handle: next, count: state.localFolder.count };
onSwitch(state); notice(`Opened ${next.name}. Review & apply writes staged changes to disk. Files load on demand; Refresh picks up external edits.`);
} catch (error) { if (error.name !== 'AbortError') notice(error.message); }
finally { opening = false; controls(); }
}
$('#open-folder').onclick = () => connect(false);
$('#reconnect-folder').onclick = () => connect(true);
$('#disconnect-folder').onclick = async () => {
try {
canSwitch(); saved = { handle, count: current.count };
const state = await rpc('local_close'); onSwitch(state);
notice('Demo workspace restored. Reconnect to continue with your local folder and staged edits.');
} catch (error) { notice(error.message); }
};
$('#refresh-folder').onclick = async () => {
try { canSwitch(); onSwitch(await rpc('local_refresh')); notice('Folder refreshed from disk. Started a new chat with the current files.'); }
catch (error) { notice(error.message); }
};
function renderReview(patches) {
reviewPatches = patches;
$('#review-files').replaceChildren(); $('#review-error').textContent = '';
$('#review-description').textContent = `Review changes to ${current.name}. Applying writes them to your local folder.`;
for (const patch of patches) {
const details = document.createElement('details'), title = document.createElement('summary'), pre = document.createElement('pre');
const kind = patch.kind === 'directory' ? 'New folder' : patch.after === null ? 'Delete' : patch.before === null ? 'Create' : 'Edit';
title.textContent = `${kind} · ${patch.path.slice(1)}`;
details.append(title); details.open = patches.length < 5;
if (patch.kind !== 'directory') {
try {
const before = text(patch.before), after = text(patch.after);
if (before.length + after.length > 160_000) pre.textContent = 'Large text change. Inspect the file in the editor before applying.';
else {
const diff = createTwoFilesPatch(patch.path, patch.path, before, after, 'on disk when read', 'staged', { context: 3 });
for (const line of diff.split('\n')) {
const span = document.createElement('span'); span.textContent = line + '\n';
if (line.startsWith('+')) span.className = 'diff-add';
if (line.startsWith('-')) span.className = 'diff-remove';
pre.append(span);
}
}
} catch { pre.textContent = `Binary file · ${patch.before === null ? 0 : atob(patch.before).length} → ${patch.after === null ? 0 : atob(patch.after).length} bytes`; }
details.append(pre);
}
$('#review-files').append(details);
}
controls();
}
$('#review-changes').onclick = async () => {
try { canSwitch(); renderReview(await rpc('local_changes')); dialog.showModal(); }
catch (error) { notice(error.message); }
};
$('#review-close').onclick = () => dialog.close();
dialog.addEventListener('cancel', event => { if (busy) event.preventDefault(); });
$('#review-apply').onclick = async () => {
try {
// Only this user gesture requests write access. The worker never prompts.
if (await handle.requestPermission({ mode: 'readwrite' }) !== 'granted') throw Error('Write access was not granted. Changes are still staged.');
const result = await rpc('local_apply');
dialog.close(); notice(`Applied ${result.applied} change${result.applied === 1 ? '' : 's'} to ${handle.name}.`);
} catch (error) {
// Application can partially succeed; refresh the review to show only the remaining edits.
try { renderReview(await rpc('local_changes')); } catch {}
$('#review-error').textContent = error.message;
}
};
$('#review-discard').onclick = async () => {
if (!window.confirm('Discard all staged changes and reload files from disk? Your local files will not be changed.')) return;
try { onSwitch(await rpc('local_discard')); dialog.close(); notice('Staged edits discarded. Folder refreshed and chat reset.'); }
catch (error) { $('#review-error').textContent = error.message; }
};
window.addEventListener('beforeunload', event => {
if (current?.count) { event.preventDefault(); event.returnValue = ''; }
});
if (new URLSearchParams(location.search).has('local-folder')) notice('Choose Link a folder to select the project you want Pi to work on.');
return {
setBusy(value) { busy = value; controls(); },
setState(value) { current = value; if (saved && value) saved.count = value.count; controls(); },
restore(value) { saved = value; controls(); },
get folderName() { return current?.name; },
get active() { return Boolean(current); },
};
}
|