cista 0.7.1__py3-none-any.whl → 1.0.0__py3-none-any.whl

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 (128) hide show
  1. cista/_version.py +1 -1
  2. cista/preview.py +146 -38
  3. cista/protocol.py +1 -2
  4. cista/serve.py +9 -0
  5. cista/util/apphelpers.py +2 -0
  6. cista/watching.py +99 -36
  7. cista/wwwroot/assets/add-file-6a41e251.js +1 -0
  8. cista/wwwroot/assets/add-folder-62d934ef.js +1 -0
  9. cista/wwwroot/assets/arrow-a3a583f8.js +1 -0
  10. cista/wwwroot/assets/arrows-h-d9e36082.js +1 -0
  11. cista/wwwroot/assets/arrows-v-5223491a.js +1 -0
  12. cista/wwwroot/assets/check-f7566407.js +1 -0
  13. cista/wwwroot/assets/code-8e0123d4.js +1 -0
  14. cista/wwwroot/assets/copy-a70a3d1c.js +1 -0
  15. cista/wwwroot/assets/create-file-d995875e.js +1 -0
  16. cista/wwwroot/assets/create-folder-c448deda.js +1 -0
  17. cista/wwwroot/assets/cross-e74ed6f4.js +1 -0
  18. cista/wwwroot/assets/disk-f1376321.js +1 -0
  19. cista/wwwroot/assets/download-bad67930.js +1 -0
  20. cista/wwwroot/assets/exclamation-afec06bb.js +1 -0
  21. cista/wwwroot/assets/eye-5fde34a1.js +1 -0
  22. cista/wwwroot/assets/find-11711648.js +1 -0
  23. cista/wwwroot/assets/fullscreen-7f61e522.js +1 -0
  24. cista/wwwroot/assets/github-c9475017.js +1 -0
  25. cista/wwwroot/assets/index-0ccc4ea2.js +28 -0
  26. cista/wwwroot/assets/index-89d850f4.css +1 -0
  27. cista/wwwroot/assets/info-b3d76e57.js +1 -0
  28. cista/wwwroot/assets/link-5cd893e4.js +1 -0
  29. cista/wwwroot/assets/logo-6090454d.js +1 -0
  30. cista/wwwroot/assets/loop-531f6994.js +1 -0
  31. cista/wwwroot/assets/menu-ed0d8c47.js +1 -0
  32. cista/wwwroot/assets/next-ee82241a.js +1 -0
  33. cista/wwwroot/assets/open-8364df82.js +1 -0
  34. cista/wwwroot/assets/paste-0f86e193.js +1 -0
  35. cista/wwwroot/assets/pause-a2dd4670.js +1 -0
  36. cista/wwwroot/assets/pencil-bfd02151.js +1 -0
  37. cista/wwwroot/assets/play-83e40a03.js +1 -0
  38. cista/wwwroot/assets/plus-f463cfbc.js +1 -0
  39. cista/wwwroot/assets/previous-cbb778b5.js +1 -0
  40. cista/wwwroot/assets/reload-907eb866.js +1 -0
  41. cista/wwwroot/assets/rename-51dadde6.js +1 -0
  42. cista/wwwroot/assets/scissors-d32350cc.js +1 -0
  43. cista/wwwroot/assets/shuffle-00a003ad.js +1 -0
  44. cista/wwwroot/assets/signin-d7bd57fd.js +1 -0
  45. cista/wwwroot/assets/signout-4c4ff7fb.js +1 -0
  46. cista/wwwroot/assets/skip-0069d8d7.js +1 -0
  47. cista/wwwroot/assets/spinner-164c9b34.js +1 -0
  48. cista/wwwroot/assets/stop-3717c27d.js +1 -0
  49. cista/wwwroot/assets/trash-449f81ef.js +1 -0
  50. cista/wwwroot/assets/triangle-3ecaf29c.js +1 -0
  51. cista/wwwroot/assets/unfullscreen-175d46cd.js +1 -0
  52. cista/wwwroot/assets/up-arrow-164a366e.js +1 -0
  53. cista/wwwroot/assets/upload-cloud-df377f1a.js +1 -0
  54. cista/wwwroot/assets/user-3b5a32bc.js +1 -0
  55. cista/wwwroot/assets/user-cog-27e2c201.js +1 -0
  56. cista/wwwroot/assets/volume-high-f4e07edd.js +1 -0
  57. cista/wwwroot/assets/volume-low-f754322d.js +1 -0
  58. cista/wwwroot/assets/volume-medium-a5806088.js +1 -0
  59. cista/wwwroot/assets/volume-mute-405763b1.js +1 -0
  60. cista/wwwroot/assets/window-cross-86cb8236.js +1 -0
  61. cista/wwwroot/assets/window-d99968ac.js +1 -0
  62. cista/wwwroot/assets/wordwrap-cdcd3f93.js +1 -0
  63. cista/wwwroot/assets/zoomin-7d13bb9b.js +1 -0
  64. cista/wwwroot/assets/zoomout-daf52018.js +1 -0
  65. cista/wwwroot/index.html +2 -2
  66. {cista-0.7.1.dist-info → cista-1.0.0.dist-info}/METADATA +85 -61
  67. cista-1.0.0.dist-info/RECORD +86 -0
  68. {cista-0.7.1.dist-info → cista-1.0.0.dist-info}/WHEEL +1 -1
  69. cista/wwwroot/assets/add-file-1b4927d9.js +0 -1
  70. cista/wwwroot/assets/add-folder-58518d6d.js +0 -1
  71. cista/wwwroot/assets/arrow-2f433e20.js +0 -1
  72. cista/wwwroot/assets/arrows-h-dc6ff860.js +0 -1
  73. cista/wwwroot/assets/arrows-v-190ce7f3.js +0 -1
  74. cista/wwwroot/assets/check-2d47c51d.js +0 -1
  75. cista/wwwroot/assets/code-1eb1bde8.js +0 -1
  76. cista/wwwroot/assets/copy-df9997fd.js +0 -1
  77. cista/wwwroot/assets/create-file-43aeaa1d.js +0 -1
  78. cista/wwwroot/assets/create-folder-24b7f831.js +0 -1
  79. cista/wwwroot/assets/cross-2506dba0.js +0 -1
  80. cista/wwwroot/assets/disk-bce03086.js +0 -1
  81. cista/wwwroot/assets/download-316bb16c.js +0 -1
  82. cista/wwwroot/assets/exclamation-78de844c.js +0 -1
  83. cista/wwwroot/assets/eye-011ddaf2.js +0 -1
  84. cista/wwwroot/assets/find-41ea8f0b.js +0 -1
  85. cista/wwwroot/assets/fullscreen-b2f612ba.js +0 -1
  86. cista/wwwroot/assets/github-46775195.js +0 -1
  87. cista/wwwroot/assets/index-a85d1928.css +0 -1
  88. cista/wwwroot/assets/index-f493c98d.js +0 -13
  89. cista/wwwroot/assets/info-ba491977.js +0 -1
  90. cista/wwwroot/assets/link-a410f3b4.js +0 -1
  91. cista/wwwroot/assets/logo-cc71618a.js +0 -1
  92. cista/wwwroot/assets/loop-2e0e9aa8.js +0 -1
  93. cista/wwwroot/assets/menu-053ab56b.js +0 -1
  94. cista/wwwroot/assets/next-8870da86.js +0 -1
  95. cista/wwwroot/assets/open-b0521d81.js +0 -1
  96. cista/wwwroot/assets/paste-e36bff60.js +0 -1
  97. cista/wwwroot/assets/pause-f46047b6.js +0 -1
  98. cista/wwwroot/assets/pencil-d0e6cd5a.js +0 -1
  99. cista/wwwroot/assets/play-6a757329.js +0 -1
  100. cista/wwwroot/assets/plus-ffac27e0.js +0 -1
  101. cista/wwwroot/assets/previous-2287ba28.js +0 -1
  102. cista/wwwroot/assets/reload-c06e5b0f.js +0 -1
  103. cista/wwwroot/assets/rename-dcb091d0.js +0 -1
  104. cista/wwwroot/assets/scissors-14398cfd.js +0 -1
  105. cista/wwwroot/assets/shuffle-0b174423.js +0 -1
  106. cista/wwwroot/assets/signin-76ad80b9.js +0 -1
  107. cista/wwwroot/assets/signout-fd85cb0e.js +0 -1
  108. cista/wwwroot/assets/skip-22e66dbc.js +0 -1
  109. cista/wwwroot/assets/spinner-582ee3e5.js +0 -1
  110. cista/wwwroot/assets/stop-43750d82.js +0 -1
  111. cista/wwwroot/assets/trash-57681982.js +0 -1
  112. cista/wwwroot/assets/triangle-0484093b.js +0 -1
  113. cista/wwwroot/assets/unfullscreen-cd6c7529.js +0 -1
  114. cista/wwwroot/assets/up-arrow-27d83c2e.js +0 -1
  115. cista/wwwroot/assets/upload-cloud-cad22ad2.js +0 -1
  116. cista/wwwroot/assets/user-4c84b4ca.js +0 -1
  117. cista/wwwroot/assets/user-cog-78aa6941.js +0 -1
  118. cista/wwwroot/assets/volume-high-9d30e7f2.js +0 -1
  119. cista/wwwroot/assets/volume-low-b5c51aea.js +0 -1
  120. cista/wwwroot/assets/volume-medium-7d55be1f.js +0 -1
  121. cista/wwwroot/assets/volume-mute-9d359929.js +0 -1
  122. cista/wwwroot/assets/window-b2596ac1.js +0 -1
  123. cista/wwwroot/assets/window-cross-031d4857.js +0 -1
  124. cista/wwwroot/assets/wordwrap-0b51ac76.js +0 -1
  125. cista/wwwroot/assets/zoomin-1264897e.js +0 -1
  126. cista/wwwroot/assets/zoomout-bd0da406.js +0 -1
  127. cista-0.7.1.dist-info/RECORD +0 -86
  128. {cista-0.7.1.dist-info → cista-1.0.0.dist-info}/entry_points.txt +0 -0
cista/_version.py CHANGED
@@ -1,2 +1,2 @@
1
1
  # This file is automatically generated by hatch build.
2
- __version__ = '0.7.1'
2
+ __version__ = '1.0.0'
cista/preview.py CHANGED
@@ -4,12 +4,14 @@ import io
4
4
  import mimetypes
5
5
  import urllib.parse
6
6
  from pathlib import PurePosixPath
7
+ from time import perf_counter
7
8
  from urllib.parse import unquote
8
9
  from wsgiref.handlers import format_date_time
9
10
 
10
11
  import av
11
- import av.datasets
12
12
  import fitz # PyMuPDF
13
+ import numpy as np
14
+ import pillow_heif
13
15
  from PIL import Image
14
16
  from sanic import Blueprint, empty, raw
15
17
  from sanic.exceptions import NotFound
@@ -18,7 +20,7 @@ from sanic.log import logger
18
20
  from cista import config
19
21
  from cista.util.filename import sanitize
20
22
 
21
- DISPLAYMATRIX = av.stream.SideData.DISPLAYMATRIX
23
+ pillow_heif.register_heif_opener()
22
24
 
23
25
  bp = Blueprint("preview", url_prefix="/preview")
24
26
 
@@ -28,20 +30,20 @@ async def preview(req, path):
28
30
  """Preview a file"""
29
31
  maxsize = int(req.args.get("px", 1024))
30
32
  maxzoom = float(req.args.get("zoom", 2.0))
31
- quality = int(req.args.get("q", 40))
33
+ quality = int(req.args.get("q", 60))
32
34
  rel = PurePosixPath(sanitize(unquote(path)))
33
35
  path = config.config.path / rel
34
36
  stat = path.lstat()
35
37
  etag = config.derived_secret(
36
38
  "preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom
37
39
  ).hex()
38
- savename = PurePosixPath(path.name).with_suffix(".webp")
40
+ savename = PurePosixPath(path.name).with_suffix(".avif")
39
41
  headers = {
40
42
  "etag": etag,
41
43
  "last-modified": format_date_time(stat.st_mtime),
42
44
  "cache-control": "max-age=604800, immutable"
43
45
  + ("" if config.config.public else ", private"),
44
- "content-type": "image/webp",
46
+ "content-type": "image/avif",
45
47
  "content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}",
46
48
  }
47
49
  if req.headers.if_none_match == etag:
@@ -60,58 +62,164 @@ async def preview(req, path):
60
62
  def dispatch(path, quality, maxsize, maxzoom):
61
63
  if path.suffix.lower() in (".pdf", ".xps", ".epub", ".mobi"):
62
64
  return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
63
- if mimetypes.guess_type(path.name)[0].startswith("video/"):
65
+ type, _ = mimetypes.guess_type(path.name)
66
+ if type and type.startswith("video/"):
64
67
  return process_video(path, quality=quality, maxsize=maxsize)
65
68
  return process_image(path, quality=quality, maxsize=maxsize)
66
69
 
67
70
 
68
71
  def process_image(path, *, maxsize, quality):
72
+ t_load_start = perf_counter()
69
73
  img = Image.open(path)
70
- w, h = img.size
71
- img.thumbnail((min(w, maxsize), min(h, maxsize)))
72
- # Fix rotation based on EXIF data
73
- try:
74
- rotate_values = {3: 180, 6: 270, 8: 90}
75
- orientation = img._getexif().get(274)
76
- if orientation in rotate_values:
77
- logger.debug(f"Rotating preview {path} by {rotate_values[orientation]}")
78
- img = img.rotate(rotate_values[orientation], expand=True)
79
- except AttributeError:
80
- ...
81
- except Exception as e:
82
- logger.error(f"Error rotating preview image: {e}")
83
- # Save as webp
74
+ # Force decode to include I/O in load timing
75
+ img.load()
76
+ t_load_end = perf_counter()
77
+ # Resize
78
+ orig_w, orig_h = img.size
79
+ t_proc_start = perf_counter()
80
+ img.thumbnail((min(orig_w, maxsize), min(orig_h, maxsize)))
81
+ t_proc_end = perf_counter()
82
+ # Save as AVIF
84
83
  imgdata = io.BytesIO()
85
- img.save(imgdata, format="webp", quality=quality, method=4)
86
- return imgdata.getvalue()
84
+ t_save_start = perf_counter()
85
+ img.save(imgdata, format="avif", quality=quality, speed=10, max_threads=1)
86
+ t_save_end = perf_counter()
87
+
88
+ ret = imgdata.getvalue()
89
+
90
+ load_ms = (t_load_end - t_load_start) * 1000
91
+ proc_ms = (t_proc_end - t_proc_start) * 1000
92
+ save_ms = (t_save_end - t_save_start) * 1000
93
+ logger.debug(
94
+ "Preview image %s: load=%.1fms process=%.1fms save=%.1fms out=%.1fKB",
95
+ path.name,
96
+ load_ms,
97
+ proc_ms,
98
+ save_ms,
99
+ len(ret) / 1024,
100
+ )
101
+
102
+ return ret
87
103
 
88
104
 
89
105
  def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
106
+ t_load_start = perf_counter()
90
107
  pdf = fitz.open(path)
91
108
  page = pdf.load_page(page_number)
92
109
  w, h = page.rect[2:4]
93
110
  zoom = min(maxsize / w, maxsize / h, maxzoom)
94
111
  mat = fitz.Matrix(zoom, zoom)
95
- pix = page.get_pixmap(matrix=mat)
96
- return pix.pil_tobytes(format="webp", quality=quality, method=4)
112
+ pix = page.get_pixmap(matrix=mat) # type: ignore[attr-defined]
113
+ t_load_end = perf_counter()
114
+
115
+ t_save_start = perf_counter()
116
+ ret = pix.pil_tobytes(format="avif", quality=quality, speed=10, max_threads=1)
117
+ t_save_end = perf_counter()
118
+
119
+ logger.debug(
120
+ "Preview pdf %s: load+render=%.1fms save=%.1fms",
121
+ path.name,
122
+ (t_load_end - t_load_start) * 1000,
123
+ (t_save_end - t_save_start) * 1000,
124
+ )
125
+ return ret
97
126
 
98
127
 
99
128
  def process_video(path, *, maxsize, quality):
100
- with av.open(str(path)) as container:
101
- stream = container.streams.video[0]
102
- stream.codec_context.skip_frame = "NONKEY"
103
- rot = stream.side_data and stream.side_data.get(DISPLAYMATRIX) or 0
104
- container.seek(container.duration // 8)
105
- img = next(container.decode(stream)).to_image()
106
- del stream
107
-
108
- img.thumbnail((maxsize, maxsize))
129
+ frame = None
109
130
  imgdata = io.BytesIO()
110
- if rot:
111
- img = img.rotate(rot, expand=True)
112
- img.save(imgdata, format="webp", quality=quality, method=4)
113
- del img
131
+ istream = ostream = icc = occ = frame = None
132
+ t_load_start = perf_counter()
133
+ # Initialize to avoid "possibly unbound" in static analysis when exceptions occur
134
+ t_load_end = t_load_start
135
+ t_save_start = t_load_start
136
+ t_save_end = t_load_start
137
+ with (
138
+ av.open(str(path)) as icontainer,
139
+ av.open(imgdata, "w", format="avif") as ocontainer,
140
+ ):
141
+ istream = icontainer.streams.video[0]
142
+ istream.codec_context.skip_frame = "NONKEY"
143
+ icontainer.seek((icontainer.duration or 0) // 8)
144
+ for frame in icontainer.decode(istream):
145
+ if frame.dts is not None:
146
+ break
147
+ else:
148
+ raise RuntimeError("No frames found in video")
149
+
150
+ # Resize frame to thumbnail size
151
+ if frame.width > maxsize or frame.height > maxsize:
152
+ scale_factor = min(maxsize / frame.width, maxsize / frame.height)
153
+ new_width = int(frame.width * scale_factor)
154
+ new_height = int(frame.height * scale_factor)
155
+ frame = frame.reformat(width=new_width, height=new_height)
156
+
157
+ # Simple rotation detection and logging
158
+ if frame.rotation:
159
+ try:
160
+ fplanes = frame.to_ndarray()
161
+ # Split into Y, U, V planes of proper dimensions
162
+ planes = [
163
+ fplanes[: frame.height],
164
+ fplanes[frame.height : frame.height + frame.height // 4].reshape(
165
+ frame.height // 2, frame.width // 2
166
+ ),
167
+ fplanes[frame.height + frame.height // 4 :].reshape(
168
+ frame.height // 2, frame.width // 2
169
+ ),
170
+ ]
171
+ # Rotate
172
+ planes = [np.rot90(p, frame.rotation // 90) for p in planes]
173
+ # Restore PyAV format
174
+ planes = np.hstack([p.flat for p in planes]).reshape(
175
+ -1, planes[0].shape[1]
176
+ )
177
+ frame = av.VideoFrame.from_ndarray(planes, format=frame.format.name)
178
+ del planes, fplanes
179
+ except Exception as e:
180
+ if "not yet supported" in str(e):
181
+ logger.warning(
182
+ f"Not rotating {path.name} preview image by {frame.rotation}°:\n PyAV: {e}"
183
+ )
184
+ else:
185
+ logger.exception(f"Error rotating video frame: {e}")
186
+ t_load_end = perf_counter()
187
+
188
+ t_save_start = perf_counter()
189
+ crf = str(int(63 * (1 - quality / 100) ** 2)) # Closely matching PIL quality-%
190
+ ostream = ocontainer.add_stream(
191
+ "av1",
192
+ options={
193
+ "crf": crf,
194
+ "usage": "realtime",
195
+ "cpu-used": "8",
196
+ "threads": "1",
197
+ },
198
+ )
199
+ assert isinstance(ostream, av.VideoStream)
200
+ ostream.width = frame.width
201
+ ostream.height = frame.height
202
+ icc = istream.codec_context
203
+ occ = ostream.codec_context
204
+
205
+ # Copy HDR metadata from input video stream
206
+ occ.color_primaries = icc.color_primaries
207
+ occ.color_trc = icc.color_trc
208
+ occ.colorspace = icc.colorspace
209
+ occ.color_range = icc.color_range
210
+
211
+ ocontainer.mux(ostream.encode(frame))
212
+ ocontainer.mux(ostream.encode(None)) # Flush the stream
213
+ t_save_end = perf_counter()
214
+
215
+ # Capture frame dimensions before cleanup
114
216
  ret = imgdata.getvalue()
115
- del imgdata
217
+ logger.debug(
218
+ "Preview video %s: load+decode=%.1fms save=%.1fms",
219
+ path.name,
220
+ (t_load_end - t_load_start) * 1000,
221
+ (t_save_end - t_save_start) * 1000,
222
+ )
223
+ del imgdata, istream, ostream, icc, occ, frame
116
224
  gc.collect()
117
225
  return ret
cista/protocol.py CHANGED
@@ -127,8 +127,7 @@ class FileEntry(msgspec.Struct, array_like=True, frozen=True):
127
127
  return f"{self.name} ({self.size}, {self.mtime})"
128
128
 
129
129
 
130
- class Update(msgspec.Struct, array_like=True):
131
- ...
130
+ class Update(msgspec.Struct, array_like=True): ...
132
131
 
133
132
 
134
133
  class UpdKeep(Update, tag="k"):
cista/serve.py CHANGED
@@ -1,5 +1,6 @@
1
1
  import os
2
2
  import re
3
+ import signal
3
4
  from pathlib import Path
4
5
 
5
6
  from sanic import Sanic
@@ -11,6 +12,14 @@ def run(*, dev=False):
11
12
  """Run Sanic main process that spawns worker processes to serve HTTP requests."""
12
13
  from .app import app
13
14
 
15
+ # Set up immediate exit on Ctrl+C for faster termination
16
+ def signal_handler(signum, frame):
17
+ print("\nReceived interrupt signal, exiting immediately...")
18
+ os._exit(0)
19
+
20
+ signal.signal(signal.SIGINT, signal_handler)
21
+ signal.signal(signal.SIGTERM, signal_handler)
22
+
14
23
  url, opts = parse_listen(config.config.listen)
15
24
  # Silence Sanic's warning about running in production rather than debug
16
25
  os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1"
cista/util/apphelpers.py CHANGED
@@ -29,6 +29,8 @@ async def handle_sanic_exception(request, e):
29
29
  if not message or not request.app.debug and code == 500:
30
30
  message = "Internal Server Error"
31
31
  message = f"⚠️ {message}" if code < 500 else f"🛑 {message}"
32
+ if code == 500:
33
+ logger.exception(e)
32
34
  # Non-browsers get JSON errors
33
35
  if "text/html" not in request.headers.accept:
34
36
  return jres(
cista/watching.py CHANGED
@@ -48,6 +48,7 @@ def treeiter(rootmod):
48
48
  def treeget(rootmod: list[FileEntry], path: PurePosixPath):
49
49
  begin = None
50
50
  ret = []
51
+
51
52
  for i, relpath, entry in treeiter(rootmod):
52
53
  if begin is None:
53
54
  if relpath == path:
@@ -57,6 +58,7 @@ def treeget(rootmod: list[FileEntry], path: PurePosixPath):
57
58
  if entry.level <= len(path.parts):
58
59
  break
59
60
  ret.append(entry)
61
+
60
62
  return begin, ret
61
63
 
62
64
 
@@ -77,28 +79,36 @@ def treeinspos(rootmod: list[FileEntry], relpath: PurePosixPath, relfile: int):
77
79
  # root
78
80
  level += 1
79
81
  continue
82
+
80
83
  ename = rel.parts[level - 1]
81
84
  name = relpath.parts[level - 1]
85
+
82
86
  esort = sortkey(ename)
83
87
  nsort = sortkey(name)
84
88
  # Non-leaf are always folders, only use relfile at leaf
85
89
  isfile = relfile if len(relpath.parts) == level else 0
90
+
86
91
  # First compare by isfile, then by sorting order and if that too matches then case sensitive
87
92
  cmp = (
88
93
  entry.isfile - isfile
89
94
  or (esort > nsort) - (esort < nsort)
90
95
  or (ename > name) - (ename < name)
91
96
  )
97
+
92
98
  if cmp > 0:
93
99
  return i
94
100
  if cmp < 0:
95
101
  continue
102
+
96
103
  level += 1
97
104
  if level > len(relpath.parts):
98
- print("ERROR: insertpos", relpath, i, entry.name, entry.level, level)
105
+ logger.error(
106
+ f"insertpos level overflow: relpath={relpath}, i={i}, entry.name={entry.name}, entry.level={entry.level}, level={level}"
107
+ )
99
108
  break
100
109
  else:
101
110
  i += 1
111
+
102
112
  return i
103
113
 
104
114
 
@@ -179,21 +189,16 @@ def update_path(rootmod: list[FileEntry], relpath: PurePosixPath, loop):
179
189
  """Called on FS updates, check the filesystem and broadcast any changes."""
180
190
  new = walk(relpath)
181
191
  obegin, old = treeget(rootmod, relpath)
192
+
182
193
  if old == new:
183
- logger.debug(
184
- f"Watch: Event without changes needed {relpath}"
185
- if old
186
- else f"Watch: Event with old and new missing: {relpath}"
187
- )
188
194
  return
195
+
189
196
  if obegin is not None:
190
197
  del rootmod[obegin : obegin + len(old)]
198
+
191
199
  if new:
192
- logger.debug(f"Watch: Update {relpath}" if old else f"Watch: Created {relpath}")
193
200
  i = treeinspos(rootmod, relpath, new[0].isfile)
194
201
  rootmod[i:i] = new
195
- else:
196
- logger.debug(f"Watch: Removed {relpath}")
197
202
 
198
203
 
199
204
  def update_space(loop):
@@ -218,17 +223,35 @@ def format_update(old, new):
218
223
  oremain, nremain = set(old), set(new)
219
224
  update = []
220
225
  keep_count = 0
226
+ iteration_count = 0
227
+ # Precompute index maps to allow deterministic tie-breaking when both
228
+ # candidates exist in both sequences but are not equal (rename/move cases)
229
+ old_pos = {e: i for i, e in enumerate(old)}
230
+ new_pos = {e: i for i, e in enumerate(new)}
231
+
221
232
  while oidx < len(old) and nidx < len(new):
233
+ iteration_count += 1
234
+
235
+ # Emergency brake for potential infinite loops
236
+ if iteration_count > 50000:
237
+ logger.error(
238
+ f"format_update potential infinite loop! iteration={iteration_count}, oidx={oidx}, nidx={nidx}"
239
+ )
240
+ raise Exception(
241
+ f"format_update infinite loop detected at iteration {iteration_count}"
242
+ )
243
+
222
244
  modified = False
223
245
  # Matching entries are kept
224
246
  if old[oidx] == new[nidx]:
225
247
  entry = old[oidx]
226
- oremain.remove(entry)
227
- nremain.remove(entry)
248
+ oremain.discard(entry)
249
+ nremain.discard(entry)
228
250
  keep_count += 1
229
251
  oidx += 1
230
252
  nidx += 1
231
253
  continue
254
+
232
255
  if keep_count > 0:
233
256
  modified = True
234
257
  update.append(UpdKeep(keep_count))
@@ -248,7 +271,7 @@ def format_update(old, new):
248
271
  insert_items = []
249
272
  while nidx < len(new) and new[nidx] not in oremain:
250
273
  entry = new[nidx]
251
- nremain.remove(entry)
274
+ nremain.discard(entry)
252
275
  insert_items.append(entry)
253
276
  nidx += 1
254
277
  if insert_items:
@@ -256,9 +279,32 @@ def format_update(old, new):
256
279
  update.append(UpdIns(insert_items))
257
280
 
258
281
  if not modified:
259
- raise Exception(
260
- f"Infinite loop in diff {nidx=} {oidx=} {len(old)=} {len(new)=}"
261
- )
282
+ # Tie-break: both items exist in both lists but don't match here.
283
+ # Decide whether to delete old[oidx] first or insert new[nidx] first
284
+ # based on which alignment is closer.
285
+ if oidx >= len(old) or nidx >= len(new):
286
+ break
287
+ cur_old = old[oidx]
288
+ cur_new = new[nidx]
289
+
290
+ pos_old_in_new = new_pos.get(cur_old)
291
+ pos_new_in_old = old_pos.get(cur_new)
292
+
293
+ # Default distances if not present (shouldn't happen if in remain sets)
294
+ dist_del = (pos_old_in_new - nidx) if pos_old_in_new is not None else 1
295
+ dist_ins = (pos_new_in_old - oidx) if pos_new_in_old is not None else 1
296
+
297
+ # Prefer the operation with smaller forward distance; tie => delete
298
+ if dist_del <= dist_ins:
299
+ # Delete current old item
300
+ oremain.discard(cur_old)
301
+ update.append(UpdDel(1))
302
+ oidx += 1
303
+ else:
304
+ # Insert current new item
305
+ nremain.discard(cur_new)
306
+ update.append(UpdIns([cur_new]))
307
+ nidx += 1
262
308
 
263
309
  # Diff any remaining
264
310
  if keep_count > 0:
@@ -311,10 +357,7 @@ def watcher_inotify(loop):
311
357
  while not quit.is_set():
312
358
  i = inotify.adapters.InotifyTree(rootpath.as_posix())
313
359
  # Initialize the tree from filesystem
314
- t0 = time.perf_counter()
315
360
  update_root(loop)
316
- t1 = time.perf_counter()
317
- logger.debug(f"Root update took {t1 - t0:.1f}s")
318
361
  trefresh = time.monotonic() + 300.0
319
362
  tspace = time.monotonic() + 5.0
320
363
  # Watch for changes (frequent wakeups needed for quiting)
@@ -335,32 +378,52 @@ def watcher_inotify(loop):
335
378
  if quit.is_set():
336
379
  return
337
380
  interesting = any(f in modified_flags for f in event[1])
338
- if event[2] == rootpath.as_posix() and event[3] == "zzz":
339
- logger.debug(f"Watch: {interesting=} {event=}")
340
381
  if interesting:
341
382
  # Update modified path
342
- t0 = time.perf_counter()
343
383
  path = PurePosixPath(event[2]) / event[3]
344
- update_path(rootmod, path.relative_to(rootpath), loop)
345
- t1 = time.perf_counter()
346
- logger.debug(f"Watch: Update {event[3]} took {t1 - t0:.1f}s")
384
+ try:
385
+ rel_path = path.relative_to(rootpath)
386
+ update_path(rootmod, rel_path, loop)
387
+ except Exception as e:
388
+ logger.error(
389
+ f"Error processing inotify event for path {path}: {e}"
390
+ )
391
+ raise
347
392
  if not dirty:
348
393
  t = time.monotonic()
349
394
  dirty = True
350
- # Wait a maximum of 0.5s to push the updates
351
- if dirty and time.monotonic() >= t + 0.5:
395
+ # Wait a maximum of 0.2s to push the updates
396
+ if dirty and time.monotonic() >= t + 0.2:
352
397
  break
353
398
  if dirty and state.root != rootmod:
354
- t0 = time.perf_counter()
355
- update = format_update(state.root, rootmod)
356
- t1 = time.perf_counter()
357
- with state.lock:
358
- broadcast(update, loop)
359
- state.root = rootmod
360
- t2 = time.perf_counter()
361
- logger.debug(
362
- f"Format update took {t1 - t0:.1f}s, broadcast {t2 - t1:.1f}s"
363
- )
399
+ try:
400
+ update = format_update(state.root, rootmod)
401
+ with state.lock:
402
+ broadcast(update, loop)
403
+ state.root = rootmod
404
+ except Exception:
405
+ logger.exception(
406
+ "format_update failed; falling back to full rescan"
407
+ )
408
+ # Fallback: full rescan and try diff again; last resort send full root
409
+ try:
410
+ fresh = walk(PurePosixPath())
411
+ try:
412
+ update = format_update(state.root, fresh)
413
+ with state.lock:
414
+ broadcast(update, loop)
415
+ state.root = fresh
416
+ except Exception:
417
+ logger.exception(
418
+ "Fallback diff failed; sending full root snapshot"
419
+ )
420
+ with state.lock:
421
+ broadcast(format_root(fresh), loop)
422
+ state.root = fresh
423
+ except Exception:
424
+ logger.exception(
425
+ "Full rescan failed; dropping this batch of updates"
426
+ )
364
427
 
365
428
  del i # Free the inotify object
366
429
 
@@ -0,0 +1 @@
1
+ import{o as t,c as o,a as r}from"./index-0ccc4ea2.js";const n={xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 28 28"};function s(a,e){return t(),o("svg",n,e[0]||(e[0]=[r("path",{d:"M19.2 2.6H6.1V29h19.8V9.3zM18.5 16v7.1h-5.3V16H8.7l7.1-7.1L23 16z"},null,-1)]))}const d={render:s};export{d as default,s as render};
@@ -0,0 +1 @@
1
+ import{o,c as t,a as r}from"./index-0ccc4ea2.js";const l={xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 32 32"};function n(s,e){return o(),t("svg",l,e[0]||(e[0]=[r("path",{d:"M29 6H16l-1-2H4L2 8h28zM0 10l2 20h28l2-20zm18.3 9.5V27h-5.6v-7.5H8l7.5-7.5 7.5 7.5z"},null,-1)]))}const d={render:n};export{d as default,n as render};
@@ -0,0 +1 @@
1
+ import{o as t,c as o,a as r}from"./index-0ccc4ea2.js";const s={xmlns:"http://www.w3.org/2000/svg",width:"640",height:"640",viewBox:"0 -32 640 640"};function a(n,e){return t(),o("svg",s,e[0]||(e[0]=[r("path",{d:"M495.46 365.98c-13.03-13.37-150.24-144.06-150.24-144.06A35.16 35.16 0 0 0 320 211.2a35.06 35.06 0 0 0-25.22 10.72s-137.2 130.7-150.27 144.06c-13 13.38-13.9 37.44 0 51.72 14 14.24 33.4 15.4 50.48 0L320 297.8l125.02 119.9c17.1 15.4 36.55 14.24 50.44 0 13.95-14.3 13.08-38.37 0-51.72"},null,-1)]))}const l={render:a};export{l as default,a as render};
@@ -0,0 +1 @@
1
+ import{o,c as t,a as r}from"./index-0ccc4ea2.js";const s={xmlns:"http://www.w3.org/2000/svg",viewBox:"-6 -2 44 36"};function n(a,e){return o(),t("svg",s,e[0]||(e[0]=[r("path",{d:"M12 18H6v4l-6-6 6-6v4h6zm8-4h6v-4l6 6-6 6v-4h-6z"},null,-1)]))}const c={render:n};export{c as default,n as render};
@@ -0,0 +1 @@
1
+ import{o,c as t,a as r}from"./index-0ccc4ea2.js";const s={xmlns:"http://www.w3.org/2000/svg",viewBox:"-2 -6 16 44"};function n(a,e){return o(),t("svg",s,e[0]||(e[0]=[r("path",{d:"M8 20v6h4l-6 6-6-6h4v-6zm-4-8V6H0l6-6 6 6H8v6z"},null,-1)]))}const c={render:n};export{c as default,n as render};
@@ -0,0 +1 @@
1
+ import{o as t,c as o,a as r}from"./index-0ccc4ea2.js";const n={xmlns:"http://www.w3.org/2000/svg",width:"512",height:"512",viewBox:"-48 0 512 512"};function s(a,e){return t(),o("svg",n,e[0]||(e[0]=[r("path",{d:"M320 96 128 288l-64-64-64 64 128 128 256-256z"},null,-1)]))}const l={render:s};export{l as default,s as render};
@@ -0,0 +1 @@
1
+ import{o as t,c as o,a as r}from"./index-0ccc4ea2.js";const n={xmlns:"http://www.w3.org/2000/svg",width:"512",height:"512",viewBox:"-24 8 512 512"};function s(a,e){return t(),o("svg",n,e[0]||(e[0]=[r("path",{d:"m304 96-48 48 112 112-112 112 48 48 144-160zm-160 0L0 256l144 160 48-48L80 256l112-112z"},null,-1)]))}const c={render:s};export{c as default,s as render};
@@ -0,0 +1 @@
1
+ import{o,c as t,a as r}from"./index-0ccc4ea2.js";const n={xmlns:"http://www.w3.org/2000/svg",viewBox:"-2 -2 36 36"};function s(h,e){return o(),t("svg",n,e[0]||(e[0]=[r("path",{d:"M26 8h-6V6l-6-6H0v24h12v8h20V14zm0 2.83L29.17 14H26zm-12-8L17.17 6H14zM2 2h10v6h6v14H2zm28 28H14v-6h6V10h4v6h6z"},null,-1)]))}const v={render:s};export{v as default,s as render};
@@ -0,0 +1 @@
1
+ import{o as c,c as t,a as o}from"./index-0ccc4ea2.js";const r={xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 32 32"};function n(s,e){return c(),t("svg",r,e[0]||(e[0]=[o("path",{d:"M19.2 2.6H6.1V29h19.8V9.3zm3 15c0 .2-.2.4-.4.4h-4.4v4.4c0 .2-.2.4-.4.4h-2.4c-.2 0-.4-.2-.4-.4V18H9.9c-.2 0-.4-.2-.4-.4v-2.4c0-.2.2-.4.4-.4h4.4v-4.4c0-.2.2-.4.4-.4H17c.2 0 .4.2.4.4v4.4h4.4c.2 0 .4.2.4.4z"},null,-1)]))}const l={render:n};export{l as default,n as render};
@@ -0,0 +1 @@
1
+ import{o as c,c as t,a as o}from"./index-0ccc4ea2.js";const r={xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 32 32"};function l(n,e){return c(),t("svg",r,e[0]||(e[0]=[o("path",{d:"M29 6H16l-1-2H4L2 8h28zM0 10l2 20h28l2-20zm22.8 11.2c0 .3-.2.5-.5.5h-5.2v5.2c0 .3-.2.5-.5.5h-2.8c-.3 0-.5-.2-.5-.5v-5.2H8.1c-.3 0-.5-.2-.5-.5v-2.8c0-.3.2-.5.5-.5h5.2v-5.2c0-.3.2-.5.5-.5h2.8c.3 0 .5.2.5.5v5.2h5.2c.3 0 .5.2.5.5z"},null,-1)]))}const a={render:l};export{a as default,l as render};
@@ -0,0 +1 @@
1
+ import{o,c as t,a as r}from"./index-0ccc4ea2.js";const s={xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 32 32"};function n(l,e){return o(),t("svg",s,e[0]||(e[0]=[r("path",{d:"M25.3 8.56 17.88 16l7.44 7.44-1.86 1.87L16 17.9l-7.44 7.4-1.86-1.85L14.12 16 6.68 8.56 8.55 6.7 16 14.12l7.44-7.44z"},null,-1)]))}const c={render:n};export{c as default,n as render};
@@ -0,0 +1 @@
1
+ import{o as t,c as o,a}from"./index-0ccc4ea2.js";const r={xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 32 32"};function s(n,e){return t(),o("svg",r,e[0]||(e[0]=[a("path",{d:"M24.27 3.2H6.4a3.2 3.2 0 0 0-3.2 3.2v19.2a3.2 3.2 0 0 0 3.2 3.2h19.2a3.2 3.2 0 0 0 3.2-3.2V8.2zm-1.87 9.6c0 .88-.72 1.6-1.6 1.6h-9.6a1.6 1.6 0 0 1-1.6-1.6v-8h12.8zm-1.6-6.4h-3.2v6.4h3.2z"},null,-1)]))}const d={render:s};export{d as default,s as render};
@@ -0,0 +1 @@
1
+ import{o as s,c as e,a as o}from"./index-0ccc4ea2.js";const t={xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 30 30"};function l(n,c){return s(),e("svg",t,c[0]||(c[0]=[o("path",{d:"M23 25.9c0-.3-.1-.6-.3-.8s-.5-.3-.8-.3-.6.1-.8.3-.3.5-.3.8.1.6.3.8.5.3.8.3.6-.1.8-.3c.2-.3.3-.5.3-.8m4.6 0c0-.3-.1-.6-.3-.8s-.5-.3-.8-.3-.6.1-.8.3-.3.5-.3.8.1.6.3.8.5.3.8.3.6-.1.8-.3c.2-.3.3-.5.3-.8m2.3-4v5.7c0 .5-.2.9-.5 1.2s-.7.5-1.2.5H1.9c-.5 0-.9-.2-1.2-.5s-.5-.7-.5-1.2v-5.7c0-.5.2-.9.5-1.2s.7-.5 1.2-.5h8.3l2.4 2.4c.7.7 1.5 1 2.4 1s1.7-.3 2.4-1l2.4-2.4h8.3c.5 0 .9.2 1.2.5q.6.45.6 1.2m-5.8-10.2c.2.5.1.9-.3 1.3l-8 8c-.2.2-.5.3-.8.3s-.6-.1-.8-.3l-8-8c-.4-.3-.5-.8-.3-1.3S6.5 11 7 11h4.6V3c0-.3.1-.6.3-.8s.5-.3.8-.3h4.6c.3 0 .6.1.8.3s.3.5.3.8v8H23c.5 0 .8.2 1.1.7"},null,-1)]))}const a={render:l};export{a as default,l as render};
@@ -0,0 +1 @@
1
+ import{o as t,c as o,a as n}from"./index-0ccc4ea2.js";const r={xmlns:"http://www.w3.org/2000/svg",width:"448",height:"448",viewBox:"-136 0 448 448"};function a(s,e){return t(),o("svg",r,e[0]||(e[0]=[n("path",{d:"M128 312v56q0 6.5-4.75 11.25T112 384H48q-6.5 0-11.25-4.75T32 368v-56q0-6.5 4.75-11.25T48 296h64q6.5 0 11.25 4.75T128 312m7.5-264-7 192q-.25 6.5-5.13 11.25T112 256H48q-6.5 0-11.38-4.75T31.5 240l-7-192q-.25-6.5 4.38-11.25T40 32h80q6.5 0 11.13 4.75T135.5 48"},null,-1)]))}const c={render:a};export{c as default,a as render};
@@ -0,0 +1 @@
1
+ import{o as s,c,a as t}from"./index-0ccc4ea2.js";const o={xmlns:"http://www.w3.org/2000/svg",viewBox:"-2 -2 36 36"};function r(a,e){return s(),c("svg",o,e[0]||(e[0]=[t("path",{d:"M29.715 16c-1.696-2.625-4.018-4.875-6.804-6.304A7.94 7.94 0 0 1 24 13.714c0 4.411-3.589 8-8 8s-8-3.589-8-8c0-1.411.375-2.804 1.089-4.018C6.303 11.125 3.982 13.375 2.285 16c3.054 4.714 7.982 8 13.714 8s10.661-3.286 13.714-8zM16.858 9.143a.87.87 0 0 0-.857-.857c-2.982 0-5.429 2.446-5.429 5.429 0 .464.393.857.857.857s.857-.393.857-.857c0-2.036 1.679-3.714 3.714-3.714a.87.87 0 0 0 .857-.857zM32 16c0 .446-.143.857-.357 1.232-3.286 5.411-9.304 9.054-15.643 9.054S3.643 22.625.357 17.232C.143 16.857 0 16.446 0 16s.143-.857.357-1.232C3.643 9.375 9.661 5.714 16 5.714s12.357 3.661 15.643 9.054c.214.375.357.786.357 1.232"},null,-1)]))}const l={render:r};export{l as default,r as render};
@@ -0,0 +1 @@
1
+ import{o as t,c as o,a as n}from"./index-0ccc4ea2.js";const r={xmlns:"http://www.w3.org/2000/svg",viewBox:"-12 -12 512 512"};function s(a,e){return t(),o("svg",r,e[0]||(e[0]=[n("path",{d:"M480 416 355.44 291.44C373.22 262.4 384 228.58 384 192 384 85.98 298 0 192 0 85.98 0 0 85.98 0 192c0 106 85.98 192 192 192 36.58 0 70.4-10.78 99.44-28.5L416 480c8.75 8.75 23.25 8.7 32 0l32-32a22.8 22.8 0 0 0 0-32m-288-96c-70.7 0-128-57.3-128-128S121.3 64 192 64s128 57.3 128 128-57.3 128-128 128"},null,-1)]))}const l={render:s};export{l as default,s as render};
@@ -0,0 +1 @@
1
+ import{o as t,c as o,a as r}from"./index-0ccc4ea2.js";const v={xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 32 32"};function n(s,e){return t(),o("svg",v,e[0]||(e[0]=[r("path",{d:"M18.7 6.7h6.6v6.6h-2.6v-4h-4zm4 16v-4h2.6v6.6h-6.6v-2.6zm-16-9.4V6.7h6.6v2.6h-4v4zm2.6 5.4v4h4v2.6H6.7v-6.6z"},null,-1)]))}const a={render:n};export{a as default,n as render};
@@ -0,0 +1 @@
1
+ import{o as e,c as o,a as r}from"./index-0ccc4ea2.js";const c={xmlns:"http://www.w3.org/2000/svg",width:"512",height:"512"};function n(s,t){return e(),o("svg",c,t[0]||(t[0]=[r("path",{d:"M256 6.3C114.6 6.3 0 121 0 262.3c0 113 73.4 209 175 243 13 2.3 17.6-5.6 17.6-12.4l-.4-48C121 460.5 106 415 106 415c-11.7-29.5-28.5-37.4-28.5-37.4-23.2-16 1.8-15.6 1.8-15.6 25.7 1.8 39.2 26.4 39.2 26.4 23 39.2 60 27.8 74.5 21.3 2.3-16.5 9-27.8 16.3-34.2C152.3 369 92.6 347 92.6 249c0-28 10-50.8 26.4-68.8-2.6-6.4-11.4-32.5 2.5-67.7 0 0 21.5-7 70.4 26.2 20-5.6 42-8.5 64-8.6 21.3.7 43.2 3 64 9 49-33 70-26 70-26 14 35.3 5 61.4 2.4 67.8 16.3 18 26.2 40.8 26.2 68.7 0 98.4-60 120-117 126.4 9.2 8 17.4 23.4 17.4 47.3l-.2 70.2c0 6.6 4.7 14.6 17.7 12 101.7-34 175-129.7 175-243C512 121 397.5 6 256 6z"},null,-1)]))}const l={render:n};export{l as default,n as render};