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); },
  };
}