web-edit 0.1a0__tar.gz → 0.1a2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. {web_edit-0.1a0/web_edit.egg-info → web_edit-0.1a2}/PKG-INFO +3 -3
  2. {web_edit-0.1a0 → web_edit-0.1a2}/README.md +2 -2
  3. {web_edit-0.1a0 → web_edit-0.1a2}/frontend/app.js +57 -2
  4. {web_edit-0.1a0 → web_edit-0.1a2}/pyproject.toml +1 -1
  5. {web_edit-0.1a0 → web_edit-0.1a2}/scripts/check_wheel.py +6 -2
  6. {web_edit-0.1a0 → web_edit-0.1a2}/tests/test_app.py +35 -1
  7. {web_edit-0.1a0 → web_edit-0.1a2}/web_edit/files.py +119 -49
  8. {web_edit-0.1a0 → web_edit-0.1a2}/web_edit/static/app.js +17 -17
  9. {web_edit-0.1a0 → web_edit-0.1a2}/web_edit/static/index.html +4 -2
  10. {web_edit-0.1a0 → web_edit-0.1a2/web_edit.egg-info}/PKG-INFO +3 -3
  11. {web_edit-0.1a0 → web_edit-0.1a2}/LICENSE +0 -0
  12. {web_edit-0.1a0 → web_edit-0.1a2}/MANIFEST.in +0 -0
  13. {web_edit-0.1a0 → web_edit-0.1a2}/frontend/build.mjs +0 -0
  14. {web_edit-0.1a0 → web_edit-0.1a2}/package-lock.json +0 -0
  15. {web_edit-0.1a0 → web_edit-0.1a2}/package.json +0 -0
  16. {web_edit-0.1a0 → web_edit-0.1a2}/setup.cfg +0 -0
  17. {web_edit-0.1a0 → web_edit-0.1a2}/tests/test_security.py +0 -0
  18. {web_edit-0.1a0 → web_edit-0.1a2}/tests/test_web_edit.py +0 -0
  19. {web_edit-0.1a0 → web_edit-0.1a2}/web_edit/__init__.py +0 -0
  20. {web_edit-0.1a0 → web_edit-0.1a2}/web_edit/__main__.py +0 -0
  21. {web_edit-0.1a0 → web_edit-0.1a2}/web_edit/app.py +0 -0
  22. {web_edit-0.1a0 → web_edit-0.1a2}/web_edit/cli.py +0 -0
  23. {web_edit-0.1a0 → web_edit-0.1a2}/web_edit/security.py +0 -0
  24. {web_edit-0.1a0 → web_edit-0.1a2}/web_edit/static/THIRD_PARTY_LICENSES.txt +0 -0
  25. {web_edit-0.1a0 → web_edit-0.1a2}/web_edit/static/app.css +0 -0
  26. {web_edit-0.1a0 → web_edit-0.1a2}/web_edit.egg-info/SOURCES.txt +0 -0
  27. {web_edit-0.1a0 → web_edit-0.1a2}/web_edit.egg-info/dependency_links.txt +0 -0
  28. {web_edit-0.1a0 → web_edit-0.1a2}/web_edit.egg-info/entry_points.txt +0 -0
  29. {web_edit-0.1a0 → web_edit-0.1a2}/web_edit.egg-info/requires.txt +0 -0
  30. {web_edit-0.1a0 → web_edit-0.1a2}/web_edit.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: web-edit
3
- Version: 0.1a0
3
+ Version: 0.1a2
4
4
  Summary: Web app for editing files in a folder
5
5
  Author: Simon Willison
6
6
  License-Expression: Apache-2.0
@@ -66,14 +66,14 @@ To protect against [DNS rebinding attacks](https://tailscale.com/docs/reference/
66
66
  - Browse folders with inline previews of images smaller than 2 MB.
67
67
  - Create and edit UTF-8 text files with bundled CodeMirror 6, including on mobile.
68
68
  - Save explicitly, with unsaved-change warnings and checks for external edits.
69
+ - Copy text to the clipboard, and images under 16 megapixels as PNG over HTTPS or localhost.
69
70
  - Upload multiple files with custom paths and names, individual progress bars, and retries.
70
71
  - Download files, or delete files and folders with inline confirmation.
71
72
 
72
73
  ## Development
73
74
 
74
75
  ```bash
75
- uv sync --extra test
76
- uv run web-edit .
76
+ uv run web-edit --help
77
77
  uv run pytest
78
78
  ```
79
79
 
@@ -44,14 +44,14 @@ To protect against [DNS rebinding attacks](https://tailscale.com/docs/reference/
44
44
  - Browse folders with inline previews of images smaller than 2 MB.
45
45
  - Create and edit UTF-8 text files with bundled CodeMirror 6, including on mobile.
46
46
  - Save explicitly, with unsaved-change warnings and checks for external edits.
47
+ - Copy text to the clipboard, and images under 16 megapixels as PNG over HTTPS or localhost.
47
48
  - Upload multiple files with custom paths and names, individual progress bars, and retries.
48
49
  - Download files, or delete files and folders with inline confirmation.
49
50
 
50
51
  ## Development
51
52
 
52
53
  ```bash
53
- uv sync --extra test
54
- uv run web-edit .
54
+ uv run web-edit --help
55
55
  uv run pytest
56
56
  ```
57
57
 
@@ -18,6 +18,10 @@ const apiURL = (endpoint, path) => `./api/${endpoint}?path=${encodeURIComponent(
18
18
  let currentFolder = "", rootName = "Files", editor = null, editingPath = "", fileVersion = null;
19
19
  let savedText = "", isNew = false, saving = false, currentHash = "", navigation = 0;
20
20
  const language = new Compartment();
21
+ // Images reach the clipboard as PNG, which needs the async clipboard API (HTTPS or localhost).
22
+ const canCopyImages = Boolean(navigator.clipboard?.write && window.ClipboardItem);
23
+ // The largest canvas iOS Safari allows; keeps PNG conversion consistent across browsers.
24
+ const COPY_PIXEL_LIMIT = 4096 * 4096;
21
25
 
22
26
  function element(tag, className, text) {
23
27
  const node = document.createElement(tag);
@@ -59,6 +63,46 @@ async function api(endpoint, options = {}) {
59
63
  if (!response.ok) throw Object.assign(new Error(data.error || `Request failed (${response.status}).`), { status: response.status, data });
60
64
  return data;
61
65
  }
66
+ async function copyText(text) {
67
+ if (navigator.clipboard?.writeText) return navigator.clipboard.writeText(text);
68
+ // Plain HTTP, such as a Tailscale IP address, has no async clipboard API.
69
+ const focused = document.activeElement;
70
+ const area = element("textarea");
71
+ area.value = text; area.readOnly = true;
72
+ area.style.cssText = "position: fixed; top: 0; left: -9999px; font-size: 16px;";
73
+ document.body.append(area);
74
+ area.select(); area.setSelectionRange(0, text.length);
75
+ const copied = document.execCommand("copy");
76
+ area.remove(); focused?.focus({ preventScroll: true });
77
+ if (!copied) throw new Error("Copy failed.");
78
+ }
79
+ function imagePNG(image) {
80
+ return new Promise((resolve, reject) => {
81
+ const canvas = element("canvas");
82
+ canvas.width = image.naturalWidth; canvas.height = image.naturalHeight;
83
+ canvas.getContext("2d").drawImage(image, 0, 0);
84
+ canvas.toBlob((blob) => blob ? resolve(blob) : reject(new Error("Conversion failed.")), "image/png");
85
+ });
86
+ }
87
+ async function copyImage() {
88
+ const image = $("viewer-content").querySelector("img");
89
+ const path = $("viewer-title").textContent;
90
+ const pixels = image.naturalWidth * image.naturalHeight;
91
+ if (!pixels) { notify("This image has no fixed size, so it cannot be copied.", true); return; }
92
+ if (pixels > COPY_PIXEL_LIMIT) { notify("Images over 16 megapixels are too large to copy. You can download it instead.", true); return; }
93
+ // Safari only allows clipboard writes started directly by the click, so pass promises.
94
+ const types = { "image/png": imagePNG(image) };
95
+ if (/\.svg$/i.test(path)) {
96
+ types["text/plain"] = fetch(apiURL("image", path)).then(async (response) => {
97
+ if (!response.ok) throw new Error("Request failed.");
98
+ return new Blob([await response.text()], { type: "text/plain" });
99
+ });
100
+ }
101
+ try {
102
+ await navigator.clipboard.write([new ClipboardItem(types)]);
103
+ notify(`Copied ${path} to the clipboard.`);
104
+ } catch { notify("Could not copy this image to the clipboard.", true); }
105
+ }
62
106
  function dirty() { return editor && editor.state.doc.toString() !== savedText; }
63
107
  function updateSaveState() {
64
108
  $("save-state").textContent = saving ? "Saving…" : dirty() ? "Unsaved changes" : isNew ? "New file · not saved yet" : "All changes saved";
@@ -218,15 +262,20 @@ function openViewer(path, message, preview) {
218
262
  $("viewer-title").textContent = path;
219
263
  $("viewer-download").href = apiURL("download", path);
220
264
  $("viewer-content").replaceChildren();
265
+ $("viewer-copy").hidden = !preview || !canCopyImages;
266
+ $("viewer-copy").disabled = true;
221
267
  if (preview) {
222
268
  const loading = loadingState("Loading image…");
223
269
  const image = element("img");
224
270
  image.alt = path; image.hidden = true;
225
- image.addEventListener("load", () => { loading.remove(); image.hidden = false; });
271
+ image.addEventListener("load", () => {
272
+ if (!image.isConnected) return;
273
+ loading.remove(); image.hidden = false; $("viewer-copy").disabled = false;
274
+ });
226
275
  image.addEventListener("error", () => {
227
276
  if (!image.isConnected) return;
228
277
  loading.replaceWith(element("p", "", "This image cannot be previewed. You can download it instead."));
229
- image.remove();
278
+ image.remove(); $("viewer-copy").hidden = true;
230
279
  });
231
280
  $("viewer-content").append(loading, image);
232
281
  image.src = apiURL("image", path);
@@ -397,6 +446,12 @@ $("new-file").addEventListener("click", () => { location.hash = routeURL("new",
397
446
  $("refresh").addEventListener("click", route);
398
447
  for (const id of ["back", "viewer-back"]) $(id).addEventListener("click", () => { location.hash = routeURL("browse", currentFolder); });
399
448
  $("save").addEventListener("click", save);
449
+ $("editor-copy").addEventListener("click", async () => {
450
+ if (!editor) return;
451
+ try { await copyText(editor.state.doc.toString()); notify("Copied to the clipboard."); }
452
+ catch { notify("Could not copy to the clipboard.", true); }
453
+ });
454
+ $("viewer-copy").addEventListener("click", copyImage);
400
455
  $("new-path").addEventListener("input", () => { if (editor) { const [name, support] = languageFor($("new-path").value); $("language-label").textContent = name; editor.dispatch({ effects: language.reconfigure(support) }); } });
401
456
  window.addEventListener("hashchange", route);
402
457
  window.addEventListener("beforeunload", (event) => { if (dirty() || saving || activeUploads) { event.preventDefault(); event.returnValue = ""; } });
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "web-edit"
3
- version = "0.1a0"
3
+ version = "0.1a2"
4
4
  description = "Web app for editing files in a folder"
5
5
  readme = "README.md"
6
6
  authors = [{name = "Simon Willison"}]
@@ -1,15 +1,19 @@
1
1
  """Install a built wheel away from the checkout and exercise its packaged app.
2
2
 
3
- Usage: uv run scripts/check_wheel.py dist/web_edit-0.1-py3-none-any.whl
3
+ Usage: uv run scripts/check_wheel.py dist/*.whl
4
4
  """
5
5
 
6
+ import argparse
6
7
  import subprocess
7
8
  import sys
8
9
  import tempfile
9
10
  import zipfile
10
11
  from pathlib import Path
11
12
 
12
- wheel = Path(sys.argv[1]).resolve()
13
+ parser = argparse.ArgumentParser(description=__doc__)
14
+ parser.add_argument("wheel", type=Path, help="Path to the single wheel to verify")
15
+ wheel = parser.parse_args().wheel.resolve()
16
+ print(f"Verifying {wheel.name}", flush=True)
13
17
  required = {
14
18
  "web_edit/static/index.html",
15
19
  "web_edit/static/app.css",
@@ -1,3 +1,4 @@
1
+ import hashlib
1
2
  import json
2
3
  import os
3
4
  import stat
@@ -8,7 +9,13 @@ import pytest
8
9
  from starlette.testclient import TestClient
9
10
 
10
11
  from web_edit.app import create_app
11
- from web_edit.files import IMAGE_LIMIT, TEXT_LIMIT, FileError, FileStore
12
+ from web_edit.files import (
13
+ IMAGE_LIMIT,
14
+ TEXT_LIMIT,
15
+ FileError,
16
+ FileStore,
17
+ preserve_endings,
18
+ )
12
19
 
13
20
 
14
21
  @pytest.fixture
@@ -55,6 +62,16 @@ def test_browse(client, tmp_path):
55
62
  assert client.get("/api/browse", params={"path": "missing"}).status_code == 404
56
63
 
57
64
 
65
+ def test_browse_nested_paths(client, tmp_path):
66
+ (tmp_path / "a" / "b").mkdir(parents=True)
67
+ (tmp_path / "a" / "b" / "c.txt").write_text("c")
68
+ root = client.get("/api/browse").json()
69
+ assert [entry["path"] for entry in root["entries"]] == ["a"]
70
+ nested = client.get("/api/browse", params={"path": "a//b/"}).json()
71
+ assert nested["path"] == "a/b"
72
+ assert [entry["path"] for entry in nested["entries"]] == ["a/b/c.txt"]
73
+
74
+
58
75
  def test_create_read_edit_download(client, tmp_path):
59
76
  path = "nested/a & #? café.txt"
60
77
  created = save(client, path, "Hello 👋\n")
@@ -64,6 +81,8 @@ def test_create_read_edit_download(client, tmp_path):
64
81
  changed = save(client, path, "Updated\n", text["version"])
65
82
  assert changed.status_code == 200
66
83
  assert changed.json()["version"] != text["version"]
84
+ reread = client.get("/api/text", params={"path": path}).json()
85
+ assert reread["version"] == changed.json()["version"]
67
86
  downloaded = client.get("/api/download", params={"path": path})
68
87
  assert downloaded.content == b"Updated\n"
69
88
  assert "attachment" in downloaded.headers["content-disposition"]
@@ -96,7 +115,9 @@ def test_invalid_unicode(client):
96
115
  (b"one\r\ntwo\r\n", "one\nchanged\n", b"one\r\nchanged\r\n"),
97
116
  (b"one\rtwo\r", "one\nchanged\n", b"one\rchanged\r"),
98
117
  (b"one\ntwo\n", "one\nchanged\n", b"one\nchanged\n"),
118
+ (b"one\ntwo\n", "one\r\nchanged\r", b"one\nchanged\n"),
99
119
  (b"one\r\ntwo\nthree\r", "one\ntwo\nthree\n", b"one\r\ntwo\nthree\r"),
120
+ (b"a\r\n\r\nb\n\nc\r\n", "a\n\nb\n\nnew\nc\n", b"a\r\n\r\nb\n\nnew\r\nc\r\n"),
100
121
  (b"\xef\xbb\xbfone\r\n", "changed\n", b"\xef\xbb\xbfchanged\r\n"),
101
122
  (b"one", "two", b"two"),
102
123
  (b"", "new\n", b"new\n"),
@@ -110,6 +131,18 @@ def test_preserves_line_endings_and_bom(client, tmp_path, original, edited, expe
110
131
  assert path.read_bytes() == expected
111
132
 
112
133
 
134
+ def test_preserves_mixed_endings_with_scattered_edits():
135
+ lines = [(f"line {i}", "\r\n" if i % 3 else "\n") for i in range(3000)]
136
+ original = "".join(line + ending for line, ending in lines)
137
+ for i in range(0, len(lines), 7):
138
+ lines[i] = (f"changed {i}", "\r\n")
139
+ lines.insert(1500, ("inserted", "\r\n"))
140
+ del lines[10]
141
+ edited = "".join(line + "\n" for line, _ in lines)
142
+ expected = "".join(line + ending for line, ending in lines)
143
+ assert preserve_endings(original, edited) == expected
144
+
145
+
113
146
  def test_preserves_permissions(client, tmp_path):
114
147
  path = tmp_path / "script.sh"
115
148
  path.write_text("echo before\n")
@@ -160,6 +193,7 @@ def test_upload_spooled_file_custom_path_and_binary(client, tmp_path):
160
193
  data = b"\x00\xff" * (1024 * 1024)
161
194
  response = upload(client, "new/folder/custom.bin", data)
162
195
  assert response.status_code == 201
196
+ assert response.json()["version"] == hashlib.sha256(data).hexdigest()
163
197
  assert (tmp_path / "new/folder/custom.bin").read_bytes() == data
164
198
  assert (
165
199
  client.get("/api/download", params={"path": "new/folder/custom.bin"}).content
@@ -8,6 +8,7 @@ import shutil
8
8
  import stat
9
9
  import tempfile
10
10
  import threading
11
+ from bisect import bisect_left
11
12
  from collections import Counter
12
13
  from difflib import SequenceMatcher
13
14
  from pathlib import Path, PurePosixPath
@@ -25,6 +26,9 @@ IMAGE_TYPES = {
25
26
  "image/vnd.microsoft.icon",
26
27
  "image/svg+xml",
27
28
  }
29
+ CONTROL_BYTES = bytes([*range(0x09), 0x0B, 0x0C, *range(0x0E, 0x20)])
30
+ # Python 3.13+ can skip the URL parsing that guess_type does for every filename.
31
+ guess_file_type = getattr(mimetypes, "guess_file_type", mimetypes.guess_type)
28
32
 
29
33
 
30
34
  class FileError(Exception):
@@ -53,30 +57,90 @@ def decode_text(data):
53
57
  raise FileError(
54
58
  "This file is not UTF-8 text. You can download it instead.", 415
55
59
  )
56
- if re.search(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", text):
60
+ # UTF-8 only uses these bytes for control characters; translate beats a regex.
61
+ if len(data.translate(None, CONTROL_BYTES)) != len(data):
57
62
  raise FileError(
58
63
  "This looks like a binary file. You can download it instead.", 415
59
64
  )
60
65
  return text
61
66
 
62
67
 
68
+ def matching_blocks(old, new):
69
+ """Yield (old index, new index, size) for runs of matching lines.
70
+
71
+ SequenceMatcher alone slows down sharply on long files with scattered edits.
72
+ As in patience diff, lines that occur once in each version anchor the
73
+ comparison, so SequenceMatcher only aligns the gaps between anchors.
74
+ """
75
+ old_counts, new_counts = Counter(old), Counter(new)
76
+ positions = {line: i for i, line in enumerate(old) if old_counts[line] == 1}
77
+ candidates = [
78
+ (positions[line], j)
79
+ for j, line in enumerate(new)
80
+ if new_counts[line] == 1 and line in positions
81
+ ]
82
+ # Keep the longest series of candidates in the same order in both versions.
83
+ tails, ends, links = [], [], []
84
+ for index, (i, _) in enumerate(candidates):
85
+ length = bisect_left(tails, i)
86
+ links.append(ends[length - 1] if length else None)
87
+ if length == len(tails):
88
+ tails.append(i)
89
+ ends.append(index)
90
+ else:
91
+ tails[length] = i
92
+ ends[length] = index
93
+ anchors = [(len(old), len(new), 0)]
94
+ index = ends[-1] if ends else None
95
+ while index is not None:
96
+ anchors.append((*candidates[index], 1))
97
+ index = links[index]
98
+ anchors.append((0, 0, 0))
99
+ a = b = 0
100
+ for i, j, size in reversed(anchors):
101
+ if i < a:
102
+ continue # Already matched by extending the previous block.
103
+ # Extend each anchor backward, compare the remaining gap, then extend forward.
104
+ start_a, start_b = i, j
105
+ while a < start_a and b < start_b and old[start_a - 1] == new[start_b - 1]:
106
+ start_a, start_b = start_a - 1, start_b - 1
107
+ if a < start_a and b < start_b:
108
+ # SequenceMatcher's autojunk heuristic handles repetitive documents.
109
+ matcher = SequenceMatcher(None, old[a:start_a], new[b:start_b])
110
+ for match in matcher.get_matching_blocks():
111
+ yield a + match.a, b + match.b, match.size
112
+ a, b = i + size, j + size
113
+ while a < len(old) and b < len(new) and old[a] == new[b]:
114
+ a, b = a + 1, b + 1
115
+ yield start_a, start_b, a - start_a
116
+
117
+
63
118
  def preserve_endings(original, edited):
64
119
  """Keep the endings of unchanged lines, including files with mixed endings."""
65
- old_lines = re.split(r"\r\n|\r|\n", original)
66
- old_endings = re.findall(r"\r\n|\r|\n", original)
67
- new_lines = re.split(r"\r\n|\r|\n", edited)
68
- default = Counter(old_endings).most_common(1)[0][0] if old_endings else "\n"
120
+ edited = edited.replace("\r\n", "\n").replace("\r", "\n")
121
+ crlf = original.count("\r\n")
122
+ counts = {
123
+ "\r\n": crlf,
124
+ "\r": original.count("\r") - crlf,
125
+ "\n": original.count("\n") - crlf,
126
+ }
127
+ used = [ending for ending, count in counts.items() if count]
128
+ if len(used) < 2:
129
+ # Files with one kind of line ending need no comparison.
130
+ return edited.replace("\n", used[0]) if used else edited
131
+ parts = re.split(r"(\r\n|\r|\n)", original)
132
+ old_lines, old_endings = parts[::2], parts[1::2]
133
+ new_lines = edited.split("\n")
134
+ default = Counter(old_endings).most_common(1)[0][0]
69
135
  endings = [default] * (len(new_lines) - 1)
70
- # SequenceMatcher's autojunk heuristic handles repetitive documents.
71
- for match in SequenceMatcher(None, old_lines, new_lines).get_matching_blocks():
72
- for offset in range(match.size):
73
- old_index, new_index = match.a + offset, match.b + offset
74
- if old_index < len(old_endings) and new_index < len(endings):
75
- endings[new_index] = old_endings[old_index]
76
- return "".join(
77
- line + (endings[i] if i < len(endings) else "")
78
- for i, line in enumerate(new_lines)
79
- )
136
+ for old_index, new_index, size in matching_blocks(old_lines, new_lines):
137
+ # The final line of each version has no ending.
138
+ size = min(size, len(old_endings) - old_index, len(endings) - new_index)
139
+ if size > 0:
140
+ endings[new_index : new_index + size] = old_endings[
141
+ old_index : old_index + size
142
+ ]
143
+ return "".join(line + ending for line, ending in zip(new_lines, endings + [""]))
80
144
 
81
145
 
82
146
  class FileStore:
@@ -114,7 +178,7 @@ class FileStore:
114
178
  return path
115
179
 
116
180
  def image_type(self, path):
117
- mime = mimetypes.guess_type(path.name)[0]
181
+ mime = guess_file_type(path.name)[0]
118
182
  return mime if mime in IMAGE_TYPES else None
119
183
 
120
184
  def browse(self, relative):
@@ -123,32 +187,36 @@ class FileStore:
123
187
  if not folder.exists():
124
188
  raise FileNotFoundError()
125
189
  raise FileError("This is not a folder.")
190
+ path = "" if folder == self.root else folder.relative_to(self.root).as_posix()
191
+ prefix = f"{path}/" if path else ""
126
192
  entries = []
127
- for child in folder.iterdir():
128
- if child.name.startswith(".web-edit-"):
129
- continue
130
- try:
131
- info = child.lstat()
132
- except FileNotFoundError:
133
- continue
134
- kind = "directory" if stat.S_ISDIR(info.st_mode) else "file"
135
- if stat.S_ISLNK(info.st_mode):
136
- kind = "symlink"
137
- elif not (stat.S_ISDIR(info.st_mode) or stat.S_ISREG(info.st_mode)):
138
- kind = "special"
139
- entries.append(
140
- {
141
- "name": child.name,
142
- "path": child.relative_to(self.root).as_posix(),
143
- "kind": kind,
144
- "size": info.st_size,
145
- "modified": info.st_mtime,
146
- "delete_version": delete_version(info),
147
- "preview": kind == "file"
148
- and info.st_size < IMAGE_LIMIT
149
- and bool(self.image_type(child)),
150
- }
151
- )
193
+ # scandir and plain strings avoid creating Path objects for every entry.
194
+ with os.scandir(folder) as children:
195
+ for child in children:
196
+ if child.name.startswith(".web-edit-"):
197
+ continue
198
+ try:
199
+ info = child.stat(follow_symlinks=False)
200
+ except FileNotFoundError:
201
+ continue
202
+ kind = "directory" if stat.S_ISDIR(info.st_mode) else "file"
203
+ if stat.S_ISLNK(info.st_mode):
204
+ kind = "symlink"
205
+ elif not (stat.S_ISDIR(info.st_mode) or stat.S_ISREG(info.st_mode)):
206
+ kind = "special"
207
+ entries.append(
208
+ {
209
+ "name": child.name,
210
+ "path": prefix + child.name,
211
+ "kind": kind,
212
+ "size": info.st_size,
213
+ "modified": info.st_mtime,
214
+ "delete_version": delete_version(info),
215
+ "preview": kind == "file"
216
+ and info.st_size < IMAGE_LIMIT
217
+ and bool(self.image_type(child)),
218
+ }
219
+ )
152
220
  entries.sort(
153
221
  key=lambda item: (
154
222
  item["kind"] != "directory",
@@ -157,9 +225,7 @@ class FileStore:
157
225
  )
158
226
  )
159
227
  return {
160
- "path": ""
161
- if folder == self.root
162
- else folder.relative_to(self.root).as_posix(),
228
+ "path": path,
163
229
  "root_name": self.root.name or "/",
164
230
  "entries": entries,
165
231
  }
@@ -192,18 +258,18 @@ class FileStore:
192
258
  def text(self, relative):
193
259
  with self.lock:
194
260
  path = self.regular_file(relative)
195
- before = version(path)
196
261
  if path.stat().st_size > TEXT_LIMIT:
197
262
  raise FileError(
198
263
  "Text editing is limited to 10 MiB. You can download this file.",
199
264
  413,
200
265
  )
201
266
  data = path.read_bytes()
202
- if version(path) != before:
267
+ current = hashlib.sha256(data).hexdigest()
268
+ if version(path) != current:
203
269
  raise FileError(
204
270
  "The file changed while opening it. Please try again.", 409
205
271
  )
206
- return {"path": relative, "content": decode_text(data), "version": before}
272
+ return {"path": relative, "content": decode_text(data), "version": current}
207
273
 
208
274
  def check_destination(self, relative, expected):
209
275
  path = self.path(relative, file=True)
@@ -233,12 +299,16 @@ class FileStore:
233
299
  target.parent.mkdir(parents=True, exist_ok=True)
234
300
  self.path(relative, file=True)
235
301
  temporary = None
302
+ # Hash while copying, so the version matches what was written without rereading.
303
+ digest = hashlib.sha256()
236
304
  try:
237
305
  with tempfile.NamedTemporaryFile(
238
306
  prefix=".web-edit-", dir=target.parent, delete=False
239
307
  ) as output:
240
308
  temporary = Path(output.name)
241
- shutil.copyfileobj(source, output, length=1024 * 1024)
309
+ while chunk := source.read(1024 * 1024):
310
+ digest.update(chunk)
311
+ output.write(chunk)
242
312
  output.flush()
243
313
  os.fsync(output.fileno())
244
314
  self.check_destination(relative, expected)
@@ -257,7 +327,7 @@ class FileStore:
257
327
  finally:
258
328
  if temporary is not None:
259
329
  temporary.unlink(missing_ok=True)
260
- return {"path": relative, "version": version(target)}
330
+ return {"path": relative, "version": digest.hexdigest()}
261
331
 
262
332
  def save_text(self, relative, content, expected):
263
333
  from io import BytesIO