MiniCPM5-2B-WebGPU-Pi-HTTP / app /src /local-folder-ui.mjs
Mike0021's picture
Rename local folder action to Link a folder with a link icon
0d68e9e verified
Raw History Blame Contribute Delete
8.06 kB
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); },
};
}