nicegui-treebrowser 0.1.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.
- nicegui_treebrowser/__init__.py +7 -0
- nicegui_treebrowser/browser.py +474 -0
- nicegui_treebrowser/filetypes.py +169 -0
- nicegui_treebrowser/fsutil.py +88 -0
- nicegui_treebrowser/py.typed +0 -0
- nicegui_treebrowser-0.1.0.dist-info/METADATA +143 -0
- nicegui_treebrowser-0.1.0.dist-info/RECORD +9 -0
- nicegui_treebrowser-0.1.0.dist-info/WHEEL +4 -0
- nicegui_treebrowser-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
"""The :class:`TreeBrowser` NiceGUI element."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import Callable, Iterable
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from nicegui import app, events, ui
|
|
13
|
+
|
|
14
|
+
from . import filetypes
|
|
15
|
+
from .fsutil import get_dir_size, looks_like_text, make_tarball, natural_size, read_text
|
|
16
|
+
|
|
17
|
+
LOG = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
__all__ = ["TreeBrowser"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# inspired by (but not copied from)
|
|
23
|
+
# https://github.com/zauberzeug/nicegui/tree/main/examples/local_file_picker
|
|
24
|
+
class TreeBrowser(ui.column):
|
|
25
|
+
"""Browse, preview and download the contents of a server-side directory.
|
|
26
|
+
|
|
27
|
+
A NiceGUI element: it renders where it is created, and takes `.classes()`,
|
|
28
|
+
`.style()`, `.props()` and `.move()` like any other `ui.*` element.
|
|
29
|
+
|
|
30
|
+
:param root: directory to expose; nothing outside it is ever served
|
|
31
|
+
:param focus: file or folder to reveal and open upon init
|
|
32
|
+
:param title: heading shown above the tree
|
|
33
|
+
:param show_hidden: include dot-files and dot-directories
|
|
34
|
+
:param exclude: predicate function returning True for paths to exclude
|
|
35
|
+
:param max_preview_bytes: largest text preview to load into the browser
|
|
36
|
+
:param max_table_rows: row cap for .csv/.tsv previews
|
|
37
|
+
:param allow_archive: show the "Download all" and per-folder archive buttons
|
|
38
|
+
:param show_full_path: show the full path of the directory
|
|
39
|
+
:param sniff_text: preview unrecognised files that look like UTF-8 text,
|
|
40
|
+
instead of offering only a download
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
_tree_container: ui.column
|
|
44
|
+
_tree: ui.tree
|
|
45
|
+
_viewer: ui.column
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
root: str | Path,
|
|
50
|
+
*,
|
|
51
|
+
focus: str | Path | None = None,
|
|
52
|
+
title: str = "Files",
|
|
53
|
+
show_hidden: bool = False,
|
|
54
|
+
exclude: Callable[[Path], bool] | None = None,
|
|
55
|
+
max_preview_bytes: int = 1000000,
|
|
56
|
+
max_table_rows: int = 5000,
|
|
57
|
+
allow_archive: bool = True,
|
|
58
|
+
show_full_path: bool = True,
|
|
59
|
+
sniff_text: bool = False,
|
|
60
|
+
) -> None:
|
|
61
|
+
resolved_root = Path(root).expanduser().resolve()
|
|
62
|
+
if not resolved_root.is_dir():
|
|
63
|
+
raise NotADirectoryError(f"{resolved_root} is not a directory")
|
|
64
|
+
|
|
65
|
+
super().__init__()
|
|
66
|
+
self.root = resolved_root
|
|
67
|
+
self.title = title
|
|
68
|
+
self.show_hidden = show_hidden
|
|
69
|
+
self.exclude = exclude
|
|
70
|
+
self.max_preview_bytes = max_preview_bytes
|
|
71
|
+
self.max_table_rows = max_table_rows
|
|
72
|
+
self.allow_archive = allow_archive
|
|
73
|
+
self.show_full_path = show_full_path
|
|
74
|
+
self.sniff_text = sniff_text
|
|
75
|
+
|
|
76
|
+
self._paths: dict[str, Path] = {}
|
|
77
|
+
self._selected: str | None = None
|
|
78
|
+
# URLs this browser has published for the current preview; see _pane()
|
|
79
|
+
self._routes: list[str] = []
|
|
80
|
+
|
|
81
|
+
self.classes("w-full min-h-[50vh] gap-0 p-0 flex-nowrap")
|
|
82
|
+
with self:
|
|
83
|
+
self._build_header()
|
|
84
|
+
with ui.splitter(value=32).classes("w-full min-h-0 flex-grow") as splitter:
|
|
85
|
+
with splitter.before:
|
|
86
|
+
self._build_sidebar()
|
|
87
|
+
with splitter.after:
|
|
88
|
+
self._viewer = ui.column().classes(
|
|
89
|
+
"w-full h-full gap-4 p-6 overflow-auto flex-nowrap"
|
|
90
|
+
)
|
|
91
|
+
if focus is None or not self.focus(focus):
|
|
92
|
+
self._show_placeholder()
|
|
93
|
+
|
|
94
|
+
def focus(self, path: str | Path, *, collapse_others: bool = True) -> bool:
|
|
95
|
+
"""Select path and open just the folders needed to reveal it.
|
|
96
|
+
|
|
97
|
+
Only the target's ancestors are expanded — an expanded sibling branch is
|
|
98
|
+
never needed to show it. The target itself stays as it is, so focusing a
|
|
99
|
+
folder reveals it without dumping its contents into the tree.
|
|
100
|
+
|
|
101
|
+
Accepts an absolute path or one relative to the root. Returns False if
|
|
102
|
+
the path lies outside the root or is not in the tree (missing, hidden,
|
|
103
|
+
or excluded), in which case the current selection is left alone. To
|
|
104
|
+
open the browser on a given path, pass `focus=` to the constructor.
|
|
105
|
+
|
|
106
|
+
:param path: the file or folder to reveal
|
|
107
|
+
:param collapse_others: close every other branch, so only this one is open
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
target = Path(path).expanduser()
|
|
111
|
+
if not target.is_absolute():
|
|
112
|
+
target = self.root / target
|
|
113
|
+
target = target.resolve()
|
|
114
|
+
try:
|
|
115
|
+
key = self._key(target)
|
|
116
|
+
except ValueError:
|
|
117
|
+
ui.notify(f"{target} is outside {self.root}", type="warning")
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
if key not in self._paths:
|
|
121
|
+
self.refresh() # the tree may predate the file
|
|
122
|
+
if key not in self._paths:
|
|
123
|
+
ui.notify(f"{target.name} is not in this tree", type="warning")
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
if collapse_others:
|
|
127
|
+
self._tree.collapse()
|
|
128
|
+
ancestors = self._ancestor_keys(target)
|
|
129
|
+
if ancestors:
|
|
130
|
+
self._tree.expand(ancestors)
|
|
131
|
+
self._tree.select(key)
|
|
132
|
+
self._selected = key
|
|
133
|
+
|
|
134
|
+
self._display(target)
|
|
135
|
+
self._scroll_to_selection()
|
|
136
|
+
return True
|
|
137
|
+
|
|
138
|
+
def refresh(self) -> None:
|
|
139
|
+
"""Re-read the directory tree from disk and redraw it."""
|
|
140
|
+
self._tree_container.clear()
|
|
141
|
+
with self._tree_container:
|
|
142
|
+
self._build_tree()
|
|
143
|
+
if self._selected is not None and self._selected not in self._paths:
|
|
144
|
+
self._selected = None
|
|
145
|
+
self._show_placeholder()
|
|
146
|
+
|
|
147
|
+
def _build_header(self) -> None:
|
|
148
|
+
with ui.row().classes(
|
|
149
|
+
"w-full items-center justify-between gap-4 px-6 py-3 "
|
|
150
|
+
"border-b border-gray-200 dark:border-gray-700"
|
|
151
|
+
):
|
|
152
|
+
with ui.column().classes("gap-0 min-w-0"):
|
|
153
|
+
ui.label(self.title).classes("text-lg font-medium leading-tight")
|
|
154
|
+
if self.show_full_path:
|
|
155
|
+
ui.label(str(self.root)).classes(
|
|
156
|
+
"text-xs text-gray-500 font-mono truncate"
|
|
157
|
+
)
|
|
158
|
+
with ui.row().classes("items-center gap-2 flex-nowrap"):
|
|
159
|
+
ui.button(icon="refresh", on_click=self.refresh).props(
|
|
160
|
+
"flat round dense"
|
|
161
|
+
).tooltip("Reload the tree from disk")
|
|
162
|
+
if self.allow_archive:
|
|
163
|
+
ui.button(
|
|
164
|
+
"Download all",
|
|
165
|
+
icon="archive",
|
|
166
|
+
on_click=lambda: self._download_archive(self.root),
|
|
167
|
+
).props("outline no-caps")
|
|
168
|
+
|
|
169
|
+
def _build_sidebar(self) -> None:
|
|
170
|
+
with ui.column().classes("w-full h-full gap-0 flex-nowrap"):
|
|
171
|
+
search = (
|
|
172
|
+
ui.input(placeholder="Filter by name")
|
|
173
|
+
.props("dense outlined clearable")
|
|
174
|
+
.classes("w-full px-3 py-2")
|
|
175
|
+
)
|
|
176
|
+
search.on_value_change(lambda e: self._filter(e.value))
|
|
177
|
+
self._tree_container = ui.column().classes(
|
|
178
|
+
"w-full min-h-0 flex-grow overflow-auto px-2 pb-4 flex-nowrap"
|
|
179
|
+
)
|
|
180
|
+
with self._tree_container:
|
|
181
|
+
self._build_tree()
|
|
182
|
+
|
|
183
|
+
def _build_tree(self) -> None:
|
|
184
|
+
self._paths.clear()
|
|
185
|
+
self._tree = ui.tree(
|
|
186
|
+
[self._node(self.root)],
|
|
187
|
+
label_key="label",
|
|
188
|
+
on_select=self._on_select,
|
|
189
|
+
).classes("w-full")
|
|
190
|
+
self._tree.expand()
|
|
191
|
+
|
|
192
|
+
def _node(self, path: Path) -> dict[str, Any]:
|
|
193
|
+
key = self._key(path)
|
|
194
|
+
self._paths[key] = path
|
|
195
|
+
node: dict[str, Any] = {
|
|
196
|
+
"id": key,
|
|
197
|
+
"label": path.name or str(path),
|
|
198
|
+
"icon": "folder" if path.is_dir() else filetypes.icon_for(path),
|
|
199
|
+
}
|
|
200
|
+
if path.is_dir():
|
|
201
|
+
node["children"] = [self._node(child) for child in self._entries(path)]
|
|
202
|
+
return node
|
|
203
|
+
|
|
204
|
+
def _entries(self, directory: Path) -> Iterable[Path]:
|
|
205
|
+
try:
|
|
206
|
+
entries = list(directory.iterdir())
|
|
207
|
+
except PermissionError:
|
|
208
|
+
return []
|
|
209
|
+
entries = [entry for entry in entries if not self._skip(entry)]
|
|
210
|
+
# directories first, then files, each alphabetically and case-insensitively
|
|
211
|
+
entries.sort(key=lambda entry: (not entry.is_dir(), entry.name.lower()))
|
|
212
|
+
return entries
|
|
213
|
+
|
|
214
|
+
def _skip(self, path: Path) -> bool:
|
|
215
|
+
if not self.show_hidden and path.name.startswith("."):
|
|
216
|
+
return True
|
|
217
|
+
if path.is_symlink(): # keeps the walk free of loops and escapes
|
|
218
|
+
return True
|
|
219
|
+
if not path.is_dir() and not path.is_file():
|
|
220
|
+
return True
|
|
221
|
+
return bool(self.exclude and self.exclude(path))
|
|
222
|
+
|
|
223
|
+
def _key(self, path: Path) -> str:
|
|
224
|
+
relative = path.relative_to(self.root).as_posix()
|
|
225
|
+
return relative or "."
|
|
226
|
+
|
|
227
|
+
def _ancestor_keys(self, path: Path) -> list[str]:
|
|
228
|
+
"""Keys of every folder between the root and *path*, outermost first."""
|
|
229
|
+
keys: list[str] = []
|
|
230
|
+
current = path
|
|
231
|
+
while current != self.root:
|
|
232
|
+
current = current.parent
|
|
233
|
+
keys.append(self._key(current))
|
|
234
|
+
keys.reverse()
|
|
235
|
+
return keys
|
|
236
|
+
|
|
237
|
+
def _resolve(self, key: str) -> Path | None:
|
|
238
|
+
"""Map a tree key back to a path, refusing anything outside the root."""
|
|
239
|
+
path = self._paths.get(key)
|
|
240
|
+
if path is None:
|
|
241
|
+
return None
|
|
242
|
+
resolved = path.resolve()
|
|
243
|
+
if resolved != self.root and self.root not in resolved.parents:
|
|
244
|
+
return None
|
|
245
|
+
return resolved if resolved.exists() else None
|
|
246
|
+
|
|
247
|
+
def _filter(self, text: str | None) -> None:
|
|
248
|
+
# QTree's own filter prop: matching nodes stay, the rest are hidden.
|
|
249
|
+
self._tree.filter = text or ""
|
|
250
|
+
|
|
251
|
+
# -- selection ---------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
# ValueChangeEventArguments only became generic in NiceGUI 3.0; the
|
|
254
|
+
# subscript is safe on 2.x because `from __future__ import annotations`
|
|
255
|
+
# keeps it an unevaluated string.
|
|
256
|
+
def _on_select(self, event: events.ValueChangeEventArguments[str | None]) -> None:
|
|
257
|
+
key = event.value
|
|
258
|
+
self._selected = key
|
|
259
|
+
if key is None:
|
|
260
|
+
self._show_placeholder()
|
|
261
|
+
return
|
|
262
|
+
path = self._resolve(key)
|
|
263
|
+
if path is None:
|
|
264
|
+
self._show_message(
|
|
265
|
+
"That item is gone", "Reload the tree to see what changed."
|
|
266
|
+
)
|
|
267
|
+
return
|
|
268
|
+
self._display(path)
|
|
269
|
+
|
|
270
|
+
def _display(self, path: Path) -> None:
|
|
271
|
+
if path.is_dir():
|
|
272
|
+
self._show_directory(path)
|
|
273
|
+
else:
|
|
274
|
+
self._show_file(path)
|
|
275
|
+
|
|
276
|
+
def _scroll_to_selection(self) -> None:
|
|
277
|
+
with self._tree_container:
|
|
278
|
+
ui.timer(0.1, self._scroll_now, once=True)
|
|
279
|
+
|
|
280
|
+
def _scroll_now(self) -> None:
|
|
281
|
+
try:
|
|
282
|
+
ui.run_javascript(f"""
|
|
283
|
+
getElement({self._tree.id})?.$el
|
|
284
|
+
?.querySelector(
|
|
285
|
+
'.q-tree__node--selected, .q-tree__node-header--selected')
|
|
286
|
+
?.scrollIntoView({{block: 'nearest'}});
|
|
287
|
+
""")
|
|
288
|
+
except Exception as e:
|
|
289
|
+
# the client went away; the scroll is cosmetic either way
|
|
290
|
+
LOG.warning(e)
|
|
291
|
+
|
|
292
|
+
def _pane(self) -> ui.element:
|
|
293
|
+
# Elements that publish their own file (ui.image, ui.audio, ui.video)
|
|
294
|
+
# drop their route when they are deleted. The PDF iframe has no such
|
|
295
|
+
# element behind it, so retire those routes here: a file stays reachable
|
|
296
|
+
# only for as long as the preview that published it is on screen.
|
|
297
|
+
for url in self._routes:
|
|
298
|
+
app.remove_route(url)
|
|
299
|
+
self._routes.clear()
|
|
300
|
+
self._viewer.clear()
|
|
301
|
+
return self._viewer
|
|
302
|
+
|
|
303
|
+
def _show_placeholder(self) -> None:
|
|
304
|
+
with (
|
|
305
|
+
self._pane(),
|
|
306
|
+
ui.column().classes("w-full h-full items-center justify-center gap-2"),
|
|
307
|
+
):
|
|
308
|
+
ui.icon("folder_open", size="3rem").classes("text-gray-400")
|
|
309
|
+
ui.label("Pick a file to read it here.").classes("text-gray-500")
|
|
310
|
+
|
|
311
|
+
def _show_message(self, heading: str, detail: str) -> None:
|
|
312
|
+
with self._pane():
|
|
313
|
+
ui.label(heading).classes("text-lg font-medium")
|
|
314
|
+
ui.label(detail).classes("text-gray-500")
|
|
315
|
+
|
|
316
|
+
def _show_directory(self, path: Path) -> None:
|
|
317
|
+
with self._pane():
|
|
318
|
+
# _resolve() has already confirmed the path, but it may still go
|
|
319
|
+
# away between that check and this read.
|
|
320
|
+
try:
|
|
321
|
+
files, directories, total = get_dir_size(path)
|
|
322
|
+
self._file_header(path)
|
|
323
|
+
except OSError as error:
|
|
324
|
+
ui.label(f"Could not read this folder: {error}").classes("text-red-600")
|
|
325
|
+
return
|
|
326
|
+
ui.label(
|
|
327
|
+
f"{files} file{'' if files == 1 else 's'} in "
|
|
328
|
+
f"{directories} subfolder{'' if directories == 1 else 's'} · "
|
|
329
|
+
f"{natural_size(total)}"
|
|
330
|
+
).classes("text-sm text-gray-500")
|
|
331
|
+
|
|
332
|
+
def _show_file(self, path: Path) -> None:
|
|
333
|
+
kind = filetypes.classify(path)
|
|
334
|
+
with self._pane():
|
|
335
|
+
# The header stats the file, so it belongs inside the guard too: the
|
|
336
|
+
# file may vanish between _resolve() confirming it and this read.
|
|
337
|
+
try:
|
|
338
|
+
self._file_header(path)
|
|
339
|
+
self._preview(path, kind)
|
|
340
|
+
except Exception as error: # unreadable file, bad CSV, broken encoding…
|
|
341
|
+
ui.label(f"Could not read this file: {error}").classes("text-red-600")
|
|
342
|
+
|
|
343
|
+
def _preview(self, path: Path, kind: filetypes.PreviewKind) -> None:
|
|
344
|
+
if kind == "image":
|
|
345
|
+
self._preview_image(path)
|
|
346
|
+
elif kind == "pdf":
|
|
347
|
+
self._preview_pdf(path)
|
|
348
|
+
elif kind == "audio":
|
|
349
|
+
self._preview_audio(path)
|
|
350
|
+
elif kind == "video":
|
|
351
|
+
self._preview_video(path)
|
|
352
|
+
elif kind == "table":
|
|
353
|
+
self._preview_table(path, filetypes.table_delimiter(path))
|
|
354
|
+
elif kind == "markdown":
|
|
355
|
+
self._preview_markdown(path)
|
|
356
|
+
elif kind == "code":
|
|
357
|
+
self._preview_code(path, filetypes.code_language(path))
|
|
358
|
+
elif self.sniff_text and looks_like_text(path):
|
|
359
|
+
self._preview_code(path, "text")
|
|
360
|
+
else:
|
|
361
|
+
ui.label(
|
|
362
|
+
"No preview for this file type — download it to open it locally."
|
|
363
|
+
).classes("text-gray-500")
|
|
364
|
+
|
|
365
|
+
def _file_header(self, path: Path) -> None:
|
|
366
|
+
info = path.stat()
|
|
367
|
+
with ui.row().classes("w-full items-start justify-between gap-4 flex-nowrap"):
|
|
368
|
+
with ui.column().classes("gap-1 min-w-0"):
|
|
369
|
+
ui.label(path.name or str(path)).classes("text-lg font-medium truncate")
|
|
370
|
+
details = [
|
|
371
|
+
datetime.fromtimestamp(info.st_mtime).strftime("%Y-%m-%d %H:%M")
|
|
372
|
+
]
|
|
373
|
+
if path.is_file():
|
|
374
|
+
details.insert(0, natural_size(info.st_size))
|
|
375
|
+
ui.label(" · ".join(details)).classes("text-xs text-gray-500")
|
|
376
|
+
with ui.row().classes("items-center gap-2 flex-nowrap"):
|
|
377
|
+
if path.is_file():
|
|
378
|
+
ui.button(
|
|
379
|
+
"Download",
|
|
380
|
+
icon="download",
|
|
381
|
+
on_click=lambda p=path: ui.download.file(p, p.name),
|
|
382
|
+
).props("outline no-caps")
|
|
383
|
+
elif self.allow_archive:
|
|
384
|
+
ui.button(
|
|
385
|
+
"Download folder",
|
|
386
|
+
icon="archive",
|
|
387
|
+
on_click=lambda p=path: self._download_archive(p),
|
|
388
|
+
).props("outline no-caps")
|
|
389
|
+
|
|
390
|
+
def _preview_code(self, path: Path, language: str) -> None:
|
|
391
|
+
text, truncated = read_text(path, self.max_preview_bytes)
|
|
392
|
+
if truncated:
|
|
393
|
+
self._truncation_note(
|
|
394
|
+
f"Showing the first {natural_size(self.max_preview_bytes)}."
|
|
395
|
+
)
|
|
396
|
+
ui.code(text, language=language).classes("w-full")
|
|
397
|
+
|
|
398
|
+
def _preview_markdown(self, path: Path) -> None:
|
|
399
|
+
text, truncated = read_text(path, self.max_preview_bytes)
|
|
400
|
+
if truncated:
|
|
401
|
+
self._truncation_note(
|
|
402
|
+
f"Showing the first {natural_size(self.max_preview_bytes)}."
|
|
403
|
+
)
|
|
404
|
+
ui.markdown(text).classes("w-full max-w-none prose dark:prose-invert")
|
|
405
|
+
|
|
406
|
+
def _preview_image(self, path: Path) -> None:
|
|
407
|
+
# ui.image publishes the file itself and retires the route when the
|
|
408
|
+
# element is deleted, so there is nothing for us to clean up.
|
|
409
|
+
ui.image(path).classes("max-w-full").style("max-height: 80vh")
|
|
410
|
+
|
|
411
|
+
def _preview_audio(self, path: Path) -> None:
|
|
412
|
+
ui.audio(path).classes("w-full")
|
|
413
|
+
|
|
414
|
+
def _preview_video(self, path: Path) -> None:
|
|
415
|
+
# ui.video serves through add_media_file, which honours Range requests,
|
|
416
|
+
# so the viewer can seek without downloading the whole file first.
|
|
417
|
+
ui.video(path).classes("w-full").style("max-height: 80vh")
|
|
418
|
+
|
|
419
|
+
def _preview_pdf(self, path: Path) -> None:
|
|
420
|
+
url = app.add_static_file(local_file=path)
|
|
421
|
+
self._routes.append(url)
|
|
422
|
+
ui.element("iframe").props(f'src="{url}"').classes(
|
|
423
|
+
"w-full min-h-[70vh] flex-grow border-0"
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
def _preview_table(self, path: Path, delimiter: str) -> None:
|
|
427
|
+
with open(path, newline="", encoding="utf-8", errors="replace") as handle:
|
|
428
|
+
reader = csv.reader(handle, delimiter=delimiter)
|
|
429
|
+
header = next(reader, None)
|
|
430
|
+
if header is None:
|
|
431
|
+
ui.label("This file is empty.").classes("text-gray-500")
|
|
432
|
+
return
|
|
433
|
+
# Synthetic field names: a CSV header may repeat itself, be blank,
|
|
434
|
+
# or collide with the row key, none of which QTable tolerates.
|
|
435
|
+
fields = [f"c{index}" for index in range(len(header))]
|
|
436
|
+
rows: list[dict[str, Any]] = []
|
|
437
|
+
truncated = False
|
|
438
|
+
for index, record in enumerate(reader):
|
|
439
|
+
if index >= self.max_table_rows:
|
|
440
|
+
truncated = True
|
|
441
|
+
break
|
|
442
|
+
row: dict[str, Any] = dict(zip(fields, record, strict=False))
|
|
443
|
+
row["_row"] = index
|
|
444
|
+
rows.append(row)
|
|
445
|
+
if truncated:
|
|
446
|
+
self._truncation_note(f"Showing the first {self.max_table_rows:,} rows.")
|
|
447
|
+
columns = [
|
|
448
|
+
{
|
|
449
|
+
"name": field,
|
|
450
|
+
"label": label,
|
|
451
|
+
"field": field,
|
|
452
|
+
"align": "left",
|
|
453
|
+
"sortable": True,
|
|
454
|
+
}
|
|
455
|
+
for field, label in zip(fields, header, strict=True)
|
|
456
|
+
]
|
|
457
|
+
ui.table(rows=rows, columns=columns, row_key="_row", pagination=25).classes(
|
|
458
|
+
"w-full"
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
@staticmethod
|
|
462
|
+
def _truncation_note(text: str) -> None:
|
|
463
|
+
with ui.row().classes("items-center gap-2 text-xs text-gray-500"):
|
|
464
|
+
ui.icon("content_cut", size="1rem")
|
|
465
|
+
ui.label(f"{text} Download the file for everything.")
|
|
466
|
+
|
|
467
|
+
def _download_archive(self, path: Path) -> None:
|
|
468
|
+
name = path.name or self.root.name or "archive"
|
|
469
|
+
try:
|
|
470
|
+
data = make_tarball(str(path), name)
|
|
471
|
+
except Exception as error:
|
|
472
|
+
ui.notify(f"Could not pack {name}.tar.gz: {error}", type="negative")
|
|
473
|
+
return
|
|
474
|
+
ui.download.content(data, f"{name}.tar.gz", media_type="application/gzip")
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Maps a filename to the way its contents should be previewed.
|
|
2
|
+
|
|
3
|
+
Every table below is a plain module-level dict or set, so an application can
|
|
4
|
+
extend them at import time, e.g. ``filetypes.CODE_LANGUAGES[".ino"] = "cpp"``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"AUDIO_SUFFIXES",
|
|
14
|
+
"CODE_LANGUAGES",
|
|
15
|
+
"IMAGE_SUFFIXES",
|
|
16
|
+
"MARKDOWN_SUFFIXES",
|
|
17
|
+
"PDF_SUFFIXES",
|
|
18
|
+
"STEM_LANGUAGES",
|
|
19
|
+
"TABLE_DELIMITERS",
|
|
20
|
+
"VIDEO_SUFFIXES",
|
|
21
|
+
"PreviewKind",
|
|
22
|
+
"classify",
|
|
23
|
+
"code_language",
|
|
24
|
+
"icon_for",
|
|
25
|
+
"table_delimiter",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
PreviewKind = Literal[
|
|
29
|
+
"image", "pdf", "audio", "video", "table", "markdown", "code", "none"
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
#: Suffixes every mainstream browser can decode in an ``<img>`` tag.
|
|
33
|
+
#: Formats they cannot (``.tif``, ``.psd``, ``.heic``) are left download-only.
|
|
34
|
+
IMAGE_SUFFIXES = {
|
|
35
|
+
".png",
|
|
36
|
+
".jpg",
|
|
37
|
+
".jpeg",
|
|
38
|
+
".gif",
|
|
39
|
+
".webp",
|
|
40
|
+
".bmp",
|
|
41
|
+
".avif",
|
|
42
|
+
".ico",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
PDF_SUFFIXES = {".pdf"}
|
|
46
|
+
|
|
47
|
+
AUDIO_SUFFIXES = {".mp3", ".wav", ".ogg", ".oga", ".m4a", ".flac", ".aac"}
|
|
48
|
+
|
|
49
|
+
VIDEO_SUFFIXES = {".mp4", ".webm", ".ogv", ".mov"}
|
|
50
|
+
|
|
51
|
+
TABLE_DELIMITERS = {
|
|
52
|
+
".csv": ",",
|
|
53
|
+
".tsv": "\t",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
MARKDOWN_SUFFIXES = {".md", ".markdown"}
|
|
57
|
+
|
|
58
|
+
#: Suffix to Pygments lexer name. ``.svg`` and ``.html`` are here on purpose:
|
|
59
|
+
#: both are served from the app's own origin, so rendering them would run any
|
|
60
|
+
#: script they carry. Showing the source is safe and still useful.
|
|
61
|
+
CODE_LANGUAGES = {
|
|
62
|
+
".bash": "bash",
|
|
63
|
+
".c": "c",
|
|
64
|
+
".cfg": "ini",
|
|
65
|
+
".config": "text",
|
|
66
|
+
".cpp": "cpp",
|
|
67
|
+
".css": "css",
|
|
68
|
+
".diff": "diff",
|
|
69
|
+
".go": "go",
|
|
70
|
+
".h": "c",
|
|
71
|
+
".hpp": "cpp",
|
|
72
|
+
".html": "html",
|
|
73
|
+
".ini": "ini",
|
|
74
|
+
".java": "java",
|
|
75
|
+
".jl": "julia",
|
|
76
|
+
".js": "javascript",
|
|
77
|
+
".json": "json",
|
|
78
|
+
".jsx": "jsx",
|
|
79
|
+
".kt": "kotlin",
|
|
80
|
+
".log": "text",
|
|
81
|
+
".lua": "lua",
|
|
82
|
+
".patch": "diff",
|
|
83
|
+
".php": "php",
|
|
84
|
+
".pl": "perl",
|
|
85
|
+
".py": "python",
|
|
86
|
+
".r": "r",
|
|
87
|
+
".rb": "ruby",
|
|
88
|
+
".rs": "rust",
|
|
89
|
+
".scala": "scala",
|
|
90
|
+
".scss": "scss",
|
|
91
|
+
".sh": "bash",
|
|
92
|
+
".sql": "sql",
|
|
93
|
+
".svg": "xml",
|
|
94
|
+
".swift": "swift",
|
|
95
|
+
".tex": "tex",
|
|
96
|
+
".toml": "toml",
|
|
97
|
+
".ts": "typescript",
|
|
98
|
+
".tsx": "tsx",
|
|
99
|
+
".txt": "text",
|
|
100
|
+
".vue": "html",
|
|
101
|
+
".xml": "xml",
|
|
102
|
+
".yaml": "yaml",
|
|
103
|
+
".yml": "yaml",
|
|
104
|
+
".zsh": "bash",
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
#: Well-known files that carry no suffix at all, keyed by lowercased name.
|
|
108
|
+
STEM_LANGUAGES = {
|
|
109
|
+
"authors": "text",
|
|
110
|
+
"changelog": "text",
|
|
111
|
+
"dockerfile": "docker",
|
|
112
|
+
"gemfile": "ruby",
|
|
113
|
+
"justfile": "make",
|
|
114
|
+
"license": "text",
|
|
115
|
+
"makefile": "make",
|
|
116
|
+
"notice": "text",
|
|
117
|
+
"rakefile": "ruby",
|
|
118
|
+
"readme": "text",
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def classify(path: Path) -> PreviewKind:
|
|
123
|
+
"""Return how *path* should be previewed, or ``"none"`` if we do not know."""
|
|
124
|
+
suffix = path.suffix.lower()
|
|
125
|
+
if suffix in IMAGE_SUFFIXES:
|
|
126
|
+
return "image"
|
|
127
|
+
if suffix in PDF_SUFFIXES:
|
|
128
|
+
return "pdf"
|
|
129
|
+
if suffix in AUDIO_SUFFIXES:
|
|
130
|
+
return "audio"
|
|
131
|
+
if suffix in VIDEO_SUFFIXES:
|
|
132
|
+
return "video"
|
|
133
|
+
if suffix in TABLE_DELIMITERS:
|
|
134
|
+
return "table"
|
|
135
|
+
if suffix in MARKDOWN_SUFFIXES:
|
|
136
|
+
return "markdown"
|
|
137
|
+
if suffix in CODE_LANGUAGES or path.name.lower() in STEM_LANGUAGES:
|
|
138
|
+
return "code"
|
|
139
|
+
return "none"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def code_language(path: Path) -> str:
|
|
143
|
+
"""Return the Pygments lexer name for *path*, defaulting to plain text."""
|
|
144
|
+
suffix = path.suffix.lower()
|
|
145
|
+
if suffix in CODE_LANGUAGES:
|
|
146
|
+
return CODE_LANGUAGES[suffix]
|
|
147
|
+
return STEM_LANGUAGES.get(path.name.lower(), "text")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def table_delimiter(path: Path) -> str:
|
|
151
|
+
"""Return the column delimiter for a tabular file, defaulting to a comma."""
|
|
152
|
+
return TABLE_DELIMITERS.get(path.suffix.lower(), ",")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
_ICONS: dict[PreviewKind, str] = {
|
|
156
|
+
"image": "image",
|
|
157
|
+
"pdf": "picture_as_pdf",
|
|
158
|
+
"audio": "audio_file",
|
|
159
|
+
"video": "video_file",
|
|
160
|
+
"table": "table_chart",
|
|
161
|
+
"markdown": "article",
|
|
162
|
+
"code": "description",
|
|
163
|
+
"none": "insert_drive_file",
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def icon_for(path: Path) -> str:
|
|
168
|
+
"""Return the Material icon name that suits *path*."""
|
|
169
|
+
return _ICONS[classify(path)]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Filesystem helpers: sizing, safe text reads and folder archives.
|
|
2
|
+
|
|
3
|
+
Deliberately stdlib-only, so the package depends on nothing but NiceGUI itself.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import io
|
|
9
|
+
import tarfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"get_dir_size",
|
|
14
|
+
"looks_like_text",
|
|
15
|
+
"make_tarball",
|
|
16
|
+
"natural_size",
|
|
17
|
+
"read_text",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
_UNITS = ("kB", "MB", "GB", "TB", "PB", "EB")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def natural_size(size: int) -> str:
|
|
24
|
+
"""Format a byte count using decimal units, e.g. ``1.5 kB`` or ``2.3 MB``."""
|
|
25
|
+
if size < 1000:
|
|
26
|
+
return f"{size} byte" if size == 1 else f"{size} bytes"
|
|
27
|
+
value = float(size)
|
|
28
|
+
for unit in _UNITS:
|
|
29
|
+
value /= 1000.0
|
|
30
|
+
if value < 1000.0 or unit == _UNITS[-1]:
|
|
31
|
+
return f"{value:.1f} {unit}"
|
|
32
|
+
raise AssertionError("unreachable") # pragma: no cover
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def make_tarball(directory: str, arcname: str) -> bytes:
|
|
36
|
+
"""Return *directory* as an in-memory gzipped tarball."""
|
|
37
|
+
buffer = io.BytesIO()
|
|
38
|
+
with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
|
|
39
|
+
archive.add(directory, arcname=arcname)
|
|
40
|
+
return buffer.getvalue()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def read_text(path: Path, limit: int) -> tuple[str, bool]:
|
|
44
|
+
"""Read at most *limit* bytes of *path* as text.
|
|
45
|
+
|
|
46
|
+
Returns the decoded text and whether the file continued past the limit.
|
|
47
|
+
Undecodable bytes become replacement characters rather than raising.
|
|
48
|
+
"""
|
|
49
|
+
with open(path, "rb") as handle:
|
|
50
|
+
raw = handle.read(limit + 1)
|
|
51
|
+
content = raw[:limit].decode("utf-8", errors="replace")
|
|
52
|
+
is_truncated = len(raw) > limit
|
|
53
|
+
return content, is_truncated
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def looks_like_text(path: Path, probe: int = 8192) -> bool:
|
|
57
|
+
"""Guess whether *path* holds UTF-8 text, by sniffing its first *probe* bytes.
|
|
58
|
+
|
|
59
|
+
A NUL byte means binary. A decode error at the very end of the probe is
|
|
60
|
+
ignored, since a multi-byte character may simply straddle the cut.
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
with open(path, "rb") as handle:
|
|
64
|
+
head = handle.read(probe)
|
|
65
|
+
except OSError:
|
|
66
|
+
return False
|
|
67
|
+
if b"\x00" in head:
|
|
68
|
+
return False
|
|
69
|
+
try:
|
|
70
|
+
head.decode("utf-8")
|
|
71
|
+
except UnicodeDecodeError as error:
|
|
72
|
+
# tolerate a character chopped in half by the probe boundary
|
|
73
|
+
return len(head) == probe and error.start >= probe - 4
|
|
74
|
+
return True
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_dir_size(directory: Path) -> tuple[int, int, int]:
|
|
78
|
+
"""Return directory's file count, directory count, and total bytes."""
|
|
79
|
+
files = directories = total = 0
|
|
80
|
+
for entry in directory.rglob("*"):
|
|
81
|
+
if entry.is_symlink():
|
|
82
|
+
continue
|
|
83
|
+
if entry.is_dir():
|
|
84
|
+
directories += 1
|
|
85
|
+
elif entry.is_file():
|
|
86
|
+
files += 1
|
|
87
|
+
total += entry.stat().st_size
|
|
88
|
+
return files, directories, total
|
|
File without changes
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: nicegui-treebrowser
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A NiceGUI element that browses, previews and downloads a server-side directory tree
|
|
5
|
+
Project-URL: Homepage, https://github.com/odoublewen/nicegui-treebrowser
|
|
6
|
+
Project-URL: Issues, https://github.com/odoublewen/nicegui-treebrowser/issues
|
|
7
|
+
Author-email: Owen Solberg <owen.solberg@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: directory-tree,file-browser,gui,nicegui,widget
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Web Environment
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Software Development :: User Interfaces
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.13
|
|
20
|
+
Requires-Dist: nicegui>=2.14; python_version < '3.14'
|
|
21
|
+
Requires-Dist: nicegui>=3.0.4; python_version >= '3.14'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# nicegui-treebrowser
|
|
25
|
+
|
|
26
|
+
[](https://github.com/odoublewen/nicegui-treebrowser/actions/workflows/tests.yml)
|
|
27
|
+
|
|
28
|
+
A [NiceGUI](https://nicegui.io) element that renders a server-side directory as a
|
|
29
|
+
browsable tree, previews the files it finds, and lets people download them
|
|
30
|
+
individually or as a `.tar.gz`.
|
|
31
|
+
|
|
32
|
+
It is an ordinary NiceGUI element: it renders where you create it and accepts
|
|
33
|
+
`.classes()`, `.style()`, `.props()` and `.move()` like anything else in `ui.*`.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install nicegui-treebrowser # or: uv add nicegui-treebrowser
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Supported versions
|
|
42
|
+
|
|
43
|
+
| Python | NiceGUI |
|
|
44
|
+
|---|---|
|
|
45
|
+
| 3.13 | 2.14+ or 3.x |
|
|
46
|
+
| 3.14 | 3.0.4+ |
|
|
47
|
+
|
|
48
|
+
NiceGUI releases before 3.0.4 pull in a `vbuild` that calls `pkgutil.find_loader`,
|
|
49
|
+
removed in Python 3.14, so they cannot be imported there at all. The dependency
|
|
50
|
+
markers pick a workable floor for you.
|
|
51
|
+
|
|
52
|
+
Python 3.15 is not supported yet: `aiohttp`, which NiceGUI requires, has no 3.15
|
|
53
|
+
wheels. Nothing in this package stands in the way — it should work as soon as the
|
|
54
|
+
wheels land.
|
|
55
|
+
|
|
56
|
+
The test suite runs against NiceGUI 2.14.1, 3.0.4 and 3.17.1.
|
|
57
|
+
|
|
58
|
+
## Quickstart
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from nicegui import ui
|
|
62
|
+
from nicegui_treebrowser import TreeBrowser
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@ui.page("/")
|
|
66
|
+
def index() -> None:
|
|
67
|
+
TreeBrowser("/srv/results", title="Results")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
ui.run()
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Options
|
|
74
|
+
|
|
75
|
+
| Parameter | Default | Meaning |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| `root` | — | Directory to expose. Nothing outside it is ever served. |
|
|
78
|
+
| `focus` | `None` | File or folder to reveal and open on load. |
|
|
79
|
+
| `title` | `"Files"` | Heading shown above the tree. |
|
|
80
|
+
| `show_hidden` | `False` | Include dot-files and dot-directories. |
|
|
81
|
+
| `exclude` | `None` | Predicate returning `True` for paths to leave out. |
|
|
82
|
+
| `max_preview_bytes` | `1_000_000` | Largest text preview loaded into the browser. |
|
|
83
|
+
| `max_table_rows` | `5000` | Row cap for `.csv`/`.tsv` previews. |
|
|
84
|
+
| `allow_archive` | `True` | Show the "Download all" and per-folder archive buttons. |
|
|
85
|
+
| `show_full_path` | `True` | Print the root's absolute path under the title. |
|
|
86
|
+
| `sniff_text` | `False` | Preview unrecognised files that look like UTF-8 text. |
|
|
87
|
+
|
|
88
|
+
Two methods are worth knowing:
|
|
89
|
+
|
|
90
|
+
- `browser.focus(path)` — select `path` and expand only the folders needed to reveal
|
|
91
|
+
it. Returns `False` if the path is outside the root or not in the tree.
|
|
92
|
+
- `browser.refresh()` — re-read the tree from disk.
|
|
93
|
+
|
|
94
|
+
## What gets previewed
|
|
95
|
+
|
|
96
|
+
| Kind | Extensions |
|
|
97
|
+
|---|---|
|
|
98
|
+
| Image | `.png` `.jpg` `.jpeg` `.gif` `.webp` `.bmp` `.avif` `.ico` |
|
|
99
|
+
| PDF | `.pdf` |
|
|
100
|
+
| Audio | `.mp3` `.wav` `.ogg` `.oga` `.m4a` `.flac` `.aac` |
|
|
101
|
+
| Video | `.mp4` `.webm` `.ogv` `.mov` |
|
|
102
|
+
| Table | `.csv` `.tsv` |
|
|
103
|
+
| Markdown | `.md` `.markdown` |
|
|
104
|
+
| Source | ~40 suffixes (`.py` `.js` `.ts` `.json` `.yaml` `.toml` `.sql` `.go` `.rs` …) plus suffixless `Dockerfile`, `Makefile`, `Gemfile`, `Rakefile`, `justfile`, `LICENSE`, `NOTICE`, `AUTHORS`, `README` and `changelog` |
|
|
105
|
+
|
|
106
|
+
Anything else offers a download. Set `sniff_text=True` to additionally preview
|
|
107
|
+
unrecognised files whose first 8 KiB decode as UTF-8 and contain no NUL bytes.
|
|
108
|
+
|
|
109
|
+
`.svg` and `.html` are shown **as source**, not rendered — see below.
|
|
110
|
+
|
|
111
|
+
The tables are plain module-level dicts, so you can extend them:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from nicegui_treebrowser import filetypes
|
|
115
|
+
|
|
116
|
+
filetypes.CODE_LANGUAGES[".ino"] = "cpp"
|
|
117
|
+
filetypes.IMAGE_SUFFIXES.add(".jxl")
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Security
|
|
121
|
+
|
|
122
|
+
Read this before pointing the widget at anything sensitive.
|
|
123
|
+
|
|
124
|
+
- **`root` is the boundary.** Tree keys are resolved back through a dictionary built
|
|
125
|
+
by walking `root`, and the result is re-checked against `root` before anything is
|
|
126
|
+
served. A key naming a path outside the root, or one that was never in the tree,
|
|
127
|
+
resolves to nothing.
|
|
128
|
+
- **Symlinks are skipped entirely**, so a link inside the root cannot be used to read
|
|
129
|
+
or traverse outside it.
|
|
130
|
+
- **`exclude` and `show_hidden` are real filters**, not just display ones. A file they
|
|
131
|
+
keep out of the tree cannot be previewed or downloaded.
|
|
132
|
+
- **Previewing a file publishes it at an app URL** for as long as the preview is on
|
|
133
|
+
screen, and downloading publishes it briefly. Those URLs are unguessable in
|
|
134
|
+
practice but carry no authentication of their own — whatever middleware protects
|
|
135
|
+
your NiceGUI app protects them too, and nothing else does. If your app is public,
|
|
136
|
+
so is anything in `root`.
|
|
137
|
+
- **`.svg` and `.html` are deliberately shown as source.** Both are served from your
|
|
138
|
+
app's own origin, so rendering them would run any script they carry with your
|
|
139
|
+
app's cookies in scope.
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
nicegui_treebrowser/__init__.py,sha256=wuZiiwPdE6kVavS0f44kCctznp0jPUDVPYihiG-aVjU,188
|
|
2
|
+
nicegui_treebrowser/browser.py,sha256=a8pQobbuJQfksTjd7pveNn6exo-8Fl3PA-9M2cqDkhQ,19199
|
|
3
|
+
nicegui_treebrowser/filetypes.py,sha256=TpHqS5eamJp1ecj944UxeWje3DlrSablbCTQwnFSgis,4120
|
|
4
|
+
nicegui_treebrowser/fsutil.py,sha256=3cb_R4ReCl9sWwojrSu0nfHaWkT4YYV5thh7S0yf5fs,2742
|
|
5
|
+
nicegui_treebrowser/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
nicegui_treebrowser-0.1.0.dist-info/METADATA,sha256=yDoiXpQSldRR9kiU3lcvODZahPGV_VFWz9XU7pjQWxY,5601
|
|
7
|
+
nicegui_treebrowser-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
8
|
+
nicegui_treebrowser-0.1.0.dist-info/licenses/LICENSE,sha256=FPJHfx2Udw2EdaFL9mbB4qtLwjfwYoqeb4Ox7BlBX3k,1069
|
|
9
|
+
nicegui_treebrowser-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Owen Solberg
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|