PageSourceSearch

https://difranzo.com/js/apps/edit.js

js difranzo.com collected 2026-10-04 00:08:23 UTC 7,460 bytes, 186 lines download raw bytes

1/* ==========================================================================
2   edit.js — a text editor that cannot save.
3
4   It opens anything on this machine that is text, it edits properly, and
5   Save does not work. That is not a limitation dressed up as a feature; it
6   is the only correct behaviour and the application says so plainly.
7
8   The reason is worth stating once, in the software rather than in a
9   README: this site has no server. There is nothing at the other end of a
10   save — no session, no account, no endpoint, nothing that could accept a
11   write. A web page that let you edit somebody's files and appeared to save
12   them would either be lying to you or be a security hole, and both of
13   those are worse than a button that refuses.
14
15   So: edit freely, the buffer is yours, it lives in this tab and dies with
16   it. `Save a copy` writes the file to your own disk through a download,
17   which is a browser doing a browser thing and involves no request at all.
18   The status line keeps a running count of what you have changed, because
19   an editor that cannot save should at least be honest about how much you
20   are about to lose.
21   ========================================================================== */
22(function (global) {
23  "use strict";
24  var el = U.el;
25
26  /* every text file on the machine, newest first */
27  function textFiles() {
28    var out = [];
29    (function walk(n) {
30      FS.children(n, false).forEach(function (k) {
31        if (FS.isDir(k)) { walk(k); return; }
32        if (k.kind !== "text") return;
33        out.push(k);
34      });
35    })(FS.home || global.DB.fs);
36    out.sort(function (a, b) { return (b.mtime || 0) - (a.mtime || 0); });
37    return out;
38  }
39
40  function mount(body, win) {
41    var files = textFiles();
42    var list = el("div", { class: "col ed-list" });
43    var area = el("textarea", { class: "ed-area", spellcheck: "false",
44                                "aria-label": "Editor" });
45    var status = el("div", { class: "statusline" });
46    var cur = null, original = "", dirty = false;
47
48    function count(s) {
49      return { ch: s.length, ln: s ? s.split("\n").length : 0,
50               wd: s.trim() ? s.trim().split(/\s+/).length : 0 };
51    }
52
53    function say(extra) {
54      if (!cur) { status.textContent = "no file open"; return; }
55      var c = count(area.value);
56      var diff = c.ch - original.length;
57      status.textContent = FS.path(cur).replace("/Users/dominic", "~") +
58        " · " + c.ln + " lines, " + U.num(c.wd) + " words, " + U.num(c.ch) + " characters" +
59        (dirty ? " · modified (" + (diff >= 0 ? "+" : "") + U.num(diff) + ")" : " · unmodified") +
60        (extra ? " · " + extra : "");
61    }
62
63    function drawList() {
64      list.textContent = "";
65      var year = null;
66      files.forEach(function (f) {
67        var y = f.mtime ? new Date(f.mtime).getFullYear() : "";
68        if (y !== year) { year = y; list.appendChild(el("div", { class: "ed-year", text: String(y) })); }
69        list.appendChild(el("div", {
70          class: "row ed-row" + (cur === f ? " sel" : ""),
71          tabindex: "0", role: "button",
72          onclick: function () { load(f); },
73          onkeydown: function (e) { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); this.click(); } }
74        }, [
75          el("span", { class: "ed-nm", text: f.name }),
76          el("span", { class: "ed-wh", text: FS.when(f) })
77        ]));
78      });
79    }
80
81    function load(f) {
82      if (dirty && !global.confirm("This buffer has changes that cannot be saved anywhere. Open another file and lose them?")) return;
83      cur = f;
84      win.route = FS.path(f).replace(/^\/Users\/dominic\/?/, "");
85      WM.setRoute(win);
86      original = FS.text(f) || "";
87      area.value = original;
88      dirty = false;
89      drawList();
90      win.setTitle("Edit — " + f.name);
91      U.look({ app: "files", key: FS.path(f), el: body, text: original,
92               label: FS.path(f).replace("/Users/dominic", "~") });
93      say();
94      area.focus();
95    }
96
97    area.addEventListener("input", function () {
98      dirty = area.value !== original;
99      say();
100    });
101
102    /* the one that refuses */
103    function save() {
104      if (!cur) return;
105      var box = el("div", { class: "ed-alert" }, [
106        el("h3", { text: "This volume is read-only" }),
107        el("p", { text:
108          "There is no server behind this page. Nothing is listening, so there " +
109          "is nowhere for a save to go — an editor on a website that appeared " +
110          "to write to somebody's disk would either be lying to you or be a " +
111          "hole, and this one would rather be a disappointment." }),
112        el("p", { text:
113          "Your changes are in this tab and nowhere else. Save a copy puts them " +
114          "on your own disk, which your browser can do without asking anything " +
115          "of anyone." }),
116        el("div", { class: "ed-alert-act" }, [
117          el("button", { class: "btn default", text: "Save a copy…",
118            onclick: function () { box.remove();
118 copy(); } }),
119          el("button", { class: "btn", text: "Keep editing",
120            onclick: function () { box.remove(); area.focus(); } })
121        ])
122      ]);
123      body.appendChild(box);
124    }
125
126    /* a download is a browser element, not a request */
127    function copy() {
128      if (!cur) return;
129      var blob = new Blob([area.value], { type: "text/plain;charset=utf-8" });
130      var url = URL.createObjectURL(blob);
131      var a = el("a", { href: url, download: cur.name, style: "display:none" });
132      body.appendChild(a);
133      a.click();
134      setTimeout(function () { URL.revokeObjectURL(url); a.remove(); }, 4000);
135      say("copy written to your own disk");
136    }
137
138    function revert() {
139      if (!cur) return;
140      area.value = original;
141      dirty = false;
142      say("reverted");
143      area.focus();
144    }
145
146    body.appendChild(el("div", { class: "shelf ed-shelf" }, [
147      el("button", { class: "btn", text: "Save", onclick: save }),
148      el("button", { class: "btn", text: "Save a copy…", onclick: copy }),
149      el("button", { class: "btn", text: "Revert", onclick: revert }),
150      el("span", { class: "spacer" }),
151      el("span", { class: "meta", style: "font-size:11px",
152        text: "edits live in this tab only · nothing is written to this machine" })
153    ]));
154    body.appendChild(el("div", { class: "browser ed-wrap" }, [
155      list, el("div", { class: "ed-pane" }, [area])
156    ]));
157    body.appendChild(status);
158
159    drawList();
160    /* `WM.open("edit", "Desktop/notes.txt")` opens straight into a file;
161       the Workspace's Open-in-Edit button uses it. */
162    var route = win.route || "";
163    var want = route && files.filter(function (f) {
164      return FS.path(f).indexOf(route) >= 0 || f.name === route;
165    })[0];
166    if (want || files.length) load(want || files[0]);
167    else {
168      area.value = "";
169      status.textContent = "no text files on this volume";
170    }
171
172    /* reopening the same window with a different route lands on that file */
173    win.navigate = function (r) {
174      var f = files.filter(function (x) {
175        return FS.path(x).indexOf(r) >= 0 || x.name === r;
176      })[0];
177      if (f) load(f);
178    };
179
180    win.onClose = function () {
181      if (dirty) try { Observer.record("browsed", "Edit — unsaved buffer discarded"); } catch (e) {}
182    };
183  }
184
185  WM.define({ id: "edit", title: "Edit", icon: "doc", w: 760, h: 540, mount: mount });
186})(window);

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.