streamlit-server-file-picker 0.1.0__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.
- streamlit_server_file_picker-0.1.0/PKG-INFO +6 -0
- streamlit_server_file_picker-0.1.0/README.md +3 -0
- streamlit_server_file_picker-0.1.0/pyproject.toml +17 -0
- streamlit_server_file_picker-0.1.0/setup.cfg +4 -0
- streamlit_server_file_picker-0.1.0/streamlit_server_file_picker/__init__.py +192 -0
- streamlit_server_file_picker-0.1.0/streamlit_server_file_picker/component/__init__.py +21 -0
- streamlit_server_file_picker-0.1.0/streamlit_server_file_picker/component/frontend/component.css +216 -0
- streamlit_server_file_picker-0.1.0/streamlit_server_file_picker/component/frontend/component.html +1 -0
- streamlit_server_file_picker-0.1.0/streamlit_server_file_picker/component/frontend/component.js +384 -0
- streamlit_server_file_picker-0.1.0/streamlit_server_file_picker.egg-info/PKG-INFO +6 -0
- streamlit_server_file_picker-0.1.0/streamlit_server_file_picker.egg-info/SOURCES.txt +12 -0
- streamlit_server_file_picker-0.1.0/streamlit_server_file_picker.egg-info/dependency_links.txt +1 -0
- streamlit_server_file_picker-0.1.0/streamlit_server_file_picker.egg-info/requires.txt +1 -0
- streamlit_server_file_picker-0.1.0/streamlit_server_file_picker.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "streamlit-server-file-picker"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Standalone Streamlit file picker dialog package"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = ["streamlit"]
|
|
11
|
+
|
|
12
|
+
[tool.setuptools.packages.find]
|
|
13
|
+
where = ["."]
|
|
14
|
+
include = ["streamlit_server_file_picker*"]
|
|
15
|
+
|
|
16
|
+
[tool.setuptools.package-data]
|
|
17
|
+
streamlit_server_file_picker = ["component/frontend/*"]
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
import streamlit as st
|
|
8
|
+
|
|
9
|
+
from .component import server_file_picker_component
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _resolve_path(value: str | Path) -> Path:
|
|
13
|
+
return Path(value).expanduser().resolve(strict=False)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _within_top_level(path: Path, top_level: Path | None) -> bool:
|
|
17
|
+
if top_level is None:
|
|
18
|
+
return True
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
return path == top_level or path.is_relative_to(top_level)
|
|
22
|
+
except AttributeError:
|
|
23
|
+
return str(path).startswith(str(top_level))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _best_existing_directory(path: Path, top_level: Path | None) -> Path:
|
|
27
|
+
candidate = _resolve_path(path)
|
|
28
|
+
|
|
29
|
+
if candidate.is_file():
|
|
30
|
+
candidate = candidate.parent
|
|
31
|
+
|
|
32
|
+
while not candidate.exists():
|
|
33
|
+
parent = candidate.parent
|
|
34
|
+
if parent == candidate:
|
|
35
|
+
break
|
|
36
|
+
candidate = parent
|
|
37
|
+
|
|
38
|
+
if candidate.is_file():
|
|
39
|
+
candidate = candidate.parent
|
|
40
|
+
|
|
41
|
+
if top_level is not None and not _within_top_level(candidate, top_level):
|
|
42
|
+
return top_level
|
|
43
|
+
|
|
44
|
+
return candidate
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _clamp_to_top_level(path: Path, top_level: Path | None) -> Path:
|
|
48
|
+
if top_level is None:
|
|
49
|
+
return _best_existing_directory(path, None)
|
|
50
|
+
|
|
51
|
+
if not _within_top_level(path, top_level):
|
|
52
|
+
return top_level
|
|
53
|
+
|
|
54
|
+
return _best_existing_directory(path, top_level)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _normalize_extensions(values: list[str] | None) -> set[str] | None:
|
|
58
|
+
if not values:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
normalized: set[str] = set()
|
|
62
|
+
for value in values:
|
|
63
|
+
item = value.strip().lower()
|
|
64
|
+
if not item:
|
|
65
|
+
continue
|
|
66
|
+
if not item.startswith("."):
|
|
67
|
+
item = f".{item}"
|
|
68
|
+
normalized.add(item)
|
|
69
|
+
|
|
70
|
+
return normalized or None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _directory_key(path: Path) -> str:
|
|
74
|
+
return hashlib.sha1(str(path).encode("utf-8")).hexdigest()[:12]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _build_directory_tree(directory: Path, show_hidden: bool) -> dict[str, Any]:
|
|
78
|
+
node: dict[str, Any] = {
|
|
79
|
+
"name": directory.name or str(directory),
|
|
80
|
+
"path": str(directory),
|
|
81
|
+
"is_dir": True,
|
|
82
|
+
"children": [],
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
entries = sorted(
|
|
87
|
+
directory.iterdir(),
|
|
88
|
+
key=lambda entry: (not entry.is_dir(), entry.name.lower()),
|
|
89
|
+
)
|
|
90
|
+
except (FileNotFoundError, PermissionError, NotADirectoryError):
|
|
91
|
+
return node
|
|
92
|
+
|
|
93
|
+
children: list[dict[str, Any]] = []
|
|
94
|
+
for entry in entries:
|
|
95
|
+
if not show_hidden and entry.name.startswith("."):
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
if entry.is_symlink() and entry.is_dir():
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
if entry.is_dir():
|
|
102
|
+
children.append(_build_directory_tree(entry, show_hidden))
|
|
103
|
+
else:
|
|
104
|
+
children.append(
|
|
105
|
+
{
|
|
106
|
+
"name": entry.name,
|
|
107
|
+
"path": str(entry),
|
|
108
|
+
"is_dir": False,
|
|
109
|
+
}
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
node["children"] = children
|
|
113
|
+
return node
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _tree_root_for(current_directory: Path, top_level_directory: Path | None) -> Path:
|
|
117
|
+
if top_level_directory is not None:
|
|
118
|
+
return top_level_directory
|
|
119
|
+
return _best_existing_directory(current_directory, None).parent
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _picker_state_key(prefix: str, suffix: str) -> str:
|
|
123
|
+
return f"{prefix}_{suffix}"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def server_file_picker_dialog(
|
|
127
|
+
current_directory: str = ".",
|
|
128
|
+
top_level_directory: str | None = None,
|
|
129
|
+
show_files: bool = True,
|
|
130
|
+
show_hidden: bool = False,
|
|
131
|
+
extensions: list[str] | None = None,
|
|
132
|
+
element_type: Literal["file", "directory"] = "file",
|
|
133
|
+
target_input_label: str = "Image Cache Path",
|
|
134
|
+
) -> str:
|
|
135
|
+
normalized_top_level = (
|
|
136
|
+
_resolve_path(top_level_directory) if top_level_directory is not None else None
|
|
137
|
+
)
|
|
138
|
+
initial_directory = _clamp_to_top_level(
|
|
139
|
+
Path(current_directory), normalized_top_level
|
|
140
|
+
)
|
|
141
|
+
normalized_extensions = _normalize_extensions(extensions)
|
|
142
|
+
root_directory = _tree_root_for(initial_directory, normalized_top_level)
|
|
143
|
+
|
|
144
|
+
state_key = f"server_file_picker_{_directory_key(initial_directory)}"
|
|
145
|
+
selected_path_key = _picker_state_key(state_key, "selected_path")
|
|
146
|
+
|
|
147
|
+
tree = _build_directory_tree(root_directory, show_hidden=show_hidden)
|
|
148
|
+
|
|
149
|
+
result = server_file_picker_component(
|
|
150
|
+
data={
|
|
151
|
+
"current_directory": str(initial_directory),
|
|
152
|
+
"root_directory": str(root_directory),
|
|
153
|
+
"top_level_directory": str(normalized_top_level)
|
|
154
|
+
if normalized_top_level is not None
|
|
155
|
+
else None,
|
|
156
|
+
"show_files": show_files,
|
|
157
|
+
"show_hidden": show_hidden,
|
|
158
|
+
"extensions": sorted(normalized_extensions) if normalized_extensions else None,
|
|
159
|
+
"element_type": element_type,
|
|
160
|
+
"tree": tree,
|
|
161
|
+
"selected_path": str(st.session_state.get(selected_path_key, "") or ""),
|
|
162
|
+
"target_input_label": target_input_label,
|
|
163
|
+
},
|
|
164
|
+
default={
|
|
165
|
+
"current_directory": str(initial_directory),
|
|
166
|
+
"selected_path": str(st.session_state.get(selected_path_key, "") or ""),
|
|
167
|
+
"confirm": None,
|
|
168
|
+
"cancel": None,
|
|
169
|
+
},
|
|
170
|
+
key=state_key,
|
|
171
|
+
on_current_directory_change=lambda: None,
|
|
172
|
+
on_selected_path_change=lambda: None,
|
|
173
|
+
on_confirm_change=lambda: None,
|
|
174
|
+
on_cancel_change=lambda: None,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
if getattr(result, "selected_path", None):
|
|
178
|
+
st.session_state[selected_path_key] = str(getattr(result, "selected_path"))
|
|
179
|
+
|
|
180
|
+
if getattr(result, "confirm", None):
|
|
181
|
+
return str(getattr(result, "selected_path", "") or "")
|
|
182
|
+
|
|
183
|
+
if getattr(result, "cancel", None):
|
|
184
|
+
return ""
|
|
185
|
+
|
|
186
|
+
return ""
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
__all__ = [
|
|
190
|
+
"server_file_picker_dialog",
|
|
191
|
+
"server_file_picker_component",
|
|
192
|
+
]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import streamlit as st
|
|
6
|
+
|
|
7
|
+
_FRONTEND_DIR = Path(__file__).with_name("frontend")
|
|
8
|
+
|
|
9
|
+
_HTML = (_FRONTEND_DIR / "component.html").read_text(encoding="utf-8")
|
|
10
|
+
_CSS = (_FRONTEND_DIR / "component.css").read_text(encoding="utf-8")
|
|
11
|
+
_JS = (_FRONTEND_DIR / "component.js").read_text(encoding="utf-8")
|
|
12
|
+
|
|
13
|
+
server_file_picker_component = st.components.v2.component(
|
|
14
|
+
"server_file_picker_component",
|
|
15
|
+
html=_HTML,
|
|
16
|
+
css=_CSS,
|
|
17
|
+
js=_JS,
|
|
18
|
+
isolate_styles=False,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = ["server_file_picker_component"]
|
streamlit_server_file_picker-0.1.0/streamlit_server_file_picker/component/frontend/component.css
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
#server-file-picker-overlay {
|
|
2
|
+
position: fixed;
|
|
3
|
+
inset: 0;
|
|
4
|
+
z-index: 999995;
|
|
5
|
+
display: grid;
|
|
6
|
+
place-items: center;
|
|
7
|
+
pointer-events: auto;
|
|
8
|
+
font-family: var(--st-font, Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
|
9
|
+
"Segoe UI", sans-serif);
|
|
10
|
+
color: var(--st-text-color, #1f1c17);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
#server-file-picker-overlay * {
|
|
14
|
+
box-sizing: border-box;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
#server-file-picker-overlay .backdrop {
|
|
18
|
+
position: absolute;
|
|
19
|
+
inset: 0;
|
|
20
|
+
background: color-mix(in srgb, var(--st-background-color, #ffffff) 18%, #000000);
|
|
21
|
+
opacity: 0.72;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
#server-file-picker-overlay .dialog {
|
|
25
|
+
position: relative;
|
|
26
|
+
width: min(980px, calc(100vw - 32px));
|
|
27
|
+
max-height: calc(100vh - 32px);
|
|
28
|
+
z-index: 1;
|
|
29
|
+
display: flex;
|
|
30
|
+
flex-direction: column;
|
|
31
|
+
border: none;
|
|
32
|
+
border-radius: 18px;
|
|
33
|
+
background: color-mix(in srgb, var(--st-background-color, #ffffff) 92%, var(--st-secondary-background-color, #f2ede3));
|
|
34
|
+
box-shadow: 0 18px 45px rgba(38, 31, 19, 0.18);
|
|
35
|
+
overflow: hidden;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
#server-file-picker-overlay .header {
|
|
39
|
+
padding: 14px;
|
|
40
|
+
border-bottom: 1px solid var(--st-border-color, rgba(128, 128, 128, 0.24));
|
|
41
|
+
background: linear-gradient(
|
|
42
|
+
180deg,
|
|
43
|
+
color-mix(in srgb, var(--st-background-color, #ffffff) 86%, transparent),
|
|
44
|
+
color-mix(in srgb, var(--st-secondary-background-color, #f2ede3) 78%, transparent)
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#server-file-picker-overlay .row {
|
|
49
|
+
display: flex;
|
|
50
|
+
align-items: center;
|
|
51
|
+
gap: 10px;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
#server-file-picker-overlay .row + .row {
|
|
55
|
+
margin-top: 10px;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#server-file-picker-overlay .button,
|
|
59
|
+
#server-file-picker-overlay .input,
|
|
60
|
+
#server-file-picker-overlay .select-button,
|
|
61
|
+
#server-file-picker-overlay .cancel-button {
|
|
62
|
+
border: 1px solid var(--st-border-color, rgba(128, 128, 128, 0.24));
|
|
63
|
+
border-radius: 12px;
|
|
64
|
+
font: inherit;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#server-file-picker-overlay .button,
|
|
68
|
+
#server-file-picker-overlay .select-button,
|
|
69
|
+
#server-file-picker-overlay .cancel-button {
|
|
70
|
+
display: inline-flex;
|
|
71
|
+
align-items: center;
|
|
72
|
+
justify-content: center;
|
|
73
|
+
gap: 8px;
|
|
74
|
+
min-height: 40px;
|
|
75
|
+
padding: 0 14px;
|
|
76
|
+
cursor: pointer;
|
|
77
|
+
background: var(--st-background-color, #fffaf3);
|
|
78
|
+
color: var(--st-text-color, #1f1c17);
|
|
79
|
+
transition: transform 120ms ease, background 120ms ease, border-color 120ms ease;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#server-file-picker-overlay .button:hover,
|
|
83
|
+
#server-file-picker-overlay .select-button:hover,
|
|
84
|
+
#server-file-picker-overlay .cancel-button:hover,
|
|
85
|
+
#server-file-picker-overlay .entry:hover {
|
|
86
|
+
background: var(--st-secondary-background-color, #ece4d7);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
#server-file-picker-overlay .button:active,
|
|
90
|
+
#server-file-picker-overlay .select-button:active,
|
|
91
|
+
#server-file-picker-overlay .cancel-button:active,
|
|
92
|
+
#server-file-picker-overlay .entry:active {
|
|
93
|
+
transform: translateY(1px);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
#server-file-picker-overlay .button:disabled,
|
|
97
|
+
#server-file-picker-overlay .select-button:disabled,
|
|
98
|
+
#server-file-picker-overlay .cancel-button:disabled {
|
|
99
|
+
cursor: not-allowed;
|
|
100
|
+
opacity: 0.45;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
#server-file-picker-overlay .input {
|
|
104
|
+
width: 100%;
|
|
105
|
+
min-width: 0;
|
|
106
|
+
min-height: 40px;
|
|
107
|
+
padding: 0 12px;
|
|
108
|
+
background: var(--st-background-color, rgba(255, 255, 255, 0.8));
|
|
109
|
+
color: var(--st-text-color, #1f1c17);
|
|
110
|
+
outline: none;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#server-file-picker-overlay .input:focus {
|
|
114
|
+
border-color: var(--st-primary-color, #1f6f78);
|
|
115
|
+
box-shadow: 0 0 0 3px color-mix(in srgb, var(--st-primary-color, #1f6f78) 18%, transparent);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
#server-file-picker-overlay .current-group {
|
|
119
|
+
flex: 1 1 auto;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
#server-file-picker-overlay .select-group {
|
|
123
|
+
width: 140px;
|
|
124
|
+
flex: 0 0 140px;
|
|
125
|
+
display: flex;
|
|
126
|
+
justify-content: flex-end;
|
|
127
|
+
gap: 8px;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
#server-file-picker-overlay .list-shell {
|
|
131
|
+
padding: 14px;
|
|
132
|
+
display: flex;
|
|
133
|
+
flex-direction: column;
|
|
134
|
+
min-height: 0;
|
|
135
|
+
flex: 1 1 auto;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
#server-file-picker-overlay .list-meta {
|
|
139
|
+
display: flex;
|
|
140
|
+
justify-content: space-between;
|
|
141
|
+
align-items: center;
|
|
142
|
+
margin-bottom: 10px;
|
|
143
|
+
color: var(--st-secondary-text-color, var(--st-text-color, #6d655a));
|
|
144
|
+
font-size: 0.93rem;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
#server-file-picker-overlay .list {
|
|
148
|
+
flex: 1 1 auto;
|
|
149
|
+
min-height: 0;
|
|
150
|
+
overflow-y: auto;
|
|
151
|
+
border: 1px solid var(--st-border-color, rgba(128, 128, 128, 0.24));
|
|
152
|
+
border-radius: 14px;
|
|
153
|
+
background: var(--st-background-color, #fffaf3);
|
|
154
|
+
padding: 8px;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
#server-file-picker-overlay .entry {
|
|
158
|
+
display: flex;
|
|
159
|
+
align-items: center;
|
|
160
|
+
gap: 10px;
|
|
161
|
+
width: 100%;
|
|
162
|
+
min-height: 40px;
|
|
163
|
+
padding: 0 10px;
|
|
164
|
+
border-radius: 10px;
|
|
165
|
+
cursor: pointer;
|
|
166
|
+
user-select: none;
|
|
167
|
+
border: 1px solid transparent;
|
|
168
|
+
background: transparent;
|
|
169
|
+
color: inherit;
|
|
170
|
+
text-align: left;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
#server-file-picker-overlay .entry + .entry {
|
|
174
|
+
margin-top: 4px;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
#server-file-picker-overlay .entry.selected {
|
|
178
|
+
background: color-mix(in srgb, var(--st-primary-color, #1f6f78) 12%, var(--st-background-color, #ffffff));
|
|
179
|
+
border-color: color-mix(in srgb, var(--st-primary-color, #1f6f78) 30%, transparent);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
#server-file-picker-overlay .entry.directory {
|
|
183
|
+
font-weight: 600;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
#server-file-picker-overlay .entry-name {
|
|
187
|
+
flex: 1 1 auto;
|
|
188
|
+
min-width: 0;
|
|
189
|
+
overflow: hidden;
|
|
190
|
+
text-overflow: ellipsis;
|
|
191
|
+
white-space: nowrap;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
#server-file-picker-overlay .entry-path {
|
|
195
|
+
color: var(--st-secondary-text-color, var(--st-text-color, #6d655a));
|
|
196
|
+
font-size: 0.82rem;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
#server-file-picker-overlay .icon {
|
|
200
|
+
font-family: "Material Symbols Outlined";
|
|
201
|
+
font-size: 20px;
|
|
202
|
+
line-height: 1;
|
|
203
|
+
color: var(--st-primary-color, #154b52);
|
|
204
|
+
font-variation-settings: "FILL" 0, "wght" 400, "GRAD" 0, "opsz" 24;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
#server-file-picker-overlay .hint {
|
|
208
|
+
color: var(--st-secondary-text-color, var(--st-text-color, #6d655a));
|
|
209
|
+
font-size: 0.9rem;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
#server-file-picker-overlay .empty {
|
|
213
|
+
padding: 18px 12px;
|
|
214
|
+
color: var(--st-secondary-text-color, var(--st-text-color, #6d655a));
|
|
215
|
+
text-align: center;
|
|
216
|
+
}
|
streamlit_server_file_picker-0.1.0/streamlit_server_file_picker/component/frontend/component.html
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<div id="server-file-picker-anchor"></div>
|
streamlit_server_file_picker-0.1.0/streamlit_server_file_picker/component/frontend/component.js
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
const OVERLAY_ID = "server-file-picker-overlay";
|
|
2
|
+
const ROOT_ID = "server-file-picker-root";
|
|
3
|
+
|
|
4
|
+
const instances = window.__serverFilePickerInstances || new Map();
|
|
5
|
+
window.__serverFilePickerInstances = instances;
|
|
6
|
+
|
|
7
|
+
function normalizePath(path) {
|
|
8
|
+
return String(path || "").replace(/\\/g, "/");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function pathBasename(path) {
|
|
12
|
+
const normalized = normalizePath(path).replace(/\/$/, "");
|
|
13
|
+
if (!normalized) {
|
|
14
|
+
return ".";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
18
|
+
return parts.length ? parts[parts.length - 1] : normalized;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parentDirectory(path) {
|
|
22
|
+
const normalized = normalizePath(path).replace(/\/$/, "");
|
|
23
|
+
if (!normalized) {
|
|
24
|
+
return ".";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
28
|
+
if (parts.length <= 1) {
|
|
29
|
+
return ".";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return normalized.startsWith("/")
|
|
33
|
+
? `/${parts.slice(0, -1).join("/")}`
|
|
34
|
+
: parts.slice(0, -1).join("/");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function escapeHtml(value) {
|
|
38
|
+
return String(value)
|
|
39
|
+
.replaceAll("&", "&")
|
|
40
|
+
.replaceAll("<", "<")
|
|
41
|
+
.replaceAll(">", ">")
|
|
42
|
+
.replaceAll('"', """)
|
|
43
|
+
.replaceAll("'", "'");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function buildDirectoryMap(tree) {
|
|
47
|
+
const map = new Map();
|
|
48
|
+
|
|
49
|
+
function walk(node) {
|
|
50
|
+
if (!node || !node.is_dir) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
map.set(normalizePath(node.path), node);
|
|
55
|
+
const children = Array.isArray(node.children) ? node.children : [];
|
|
56
|
+
for (const child of children) {
|
|
57
|
+
if (child && child.is_dir) {
|
|
58
|
+
walk(child);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
walk(tree);
|
|
64
|
+
return map;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function getCurrentEntries(treeMap, currentDirectory) {
|
|
68
|
+
const node = treeMap.get(normalizePath(currentDirectory));
|
|
69
|
+
if (!node) {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return Array.isArray(node.children) ? node.children : [];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function updateTextInput(targetLabel, value) {
|
|
77
|
+
if (!targetLabel) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const selector = `input[aria-label="${CSS.escape(targetLabel)}"]`;
|
|
82
|
+
const input = document.querySelector(selector);
|
|
83
|
+
if (!input) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const prototype = Object.getPrototypeOf(input);
|
|
88
|
+
const descriptor = Object.getOwnPropertyDescriptor(prototype, "value");
|
|
89
|
+
if (descriptor && descriptor.set) {
|
|
90
|
+
descriptor.set.call(input, value);
|
|
91
|
+
} else {
|
|
92
|
+
input.value = value;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
96
|
+
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function ensureOverlay() {
|
|
100
|
+
let overlay = document.getElementById(OVERLAY_ID);
|
|
101
|
+
if (overlay) {
|
|
102
|
+
return overlay;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
overlay = document.createElement("div");
|
|
106
|
+
overlay.id = OVERLAY_ID;
|
|
107
|
+
overlay.innerHTML = `
|
|
108
|
+
<div class="backdrop"></div>
|
|
109
|
+
<div class="dialog" role="dialog" aria-modal="true">
|
|
110
|
+
<div class="header">
|
|
111
|
+
<div class="row">
|
|
112
|
+
<button class="button up-button" type="button" aria-label="Up one level" title="Up one level">
|
|
113
|
+
<span class="icon">arrow_upward</span>
|
|
114
|
+
</button>
|
|
115
|
+
<div class="current-group">
|
|
116
|
+
<input
|
|
117
|
+
class="input directory-input"
|
|
118
|
+
type="text"
|
|
119
|
+
spellcheck="false"
|
|
120
|
+
autocomplete="off"
|
|
121
|
+
autocapitalize="off"
|
|
122
|
+
placeholder="Current directory"
|
|
123
|
+
aria-label="Current directory"
|
|
124
|
+
/>
|
|
125
|
+
</div>
|
|
126
|
+
<div class="select-group">
|
|
127
|
+
<button class="cancel-button cancel-action" type="button">
|
|
128
|
+
<span class="icon">close</span>
|
|
129
|
+
<span>Cancel</span>
|
|
130
|
+
</button>
|
|
131
|
+
<button class="select-button select-action" type="button">
|
|
132
|
+
<span class="icon">check</span>
|
|
133
|
+
<span>Select</span>
|
|
134
|
+
</button>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
<div class="list-shell">
|
|
139
|
+
<div class="list-meta">
|
|
140
|
+
<span class="subtitle"></span>
|
|
141
|
+
<span class="hint">Single click a directory to enter it. Double click a file to confirm.</span>
|
|
142
|
+
</div>
|
|
143
|
+
<div class="list"></div>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
`;
|
|
147
|
+
|
|
148
|
+
document.body.appendChild(overlay);
|
|
149
|
+
return overlay;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function renderEntry(instance, entry) {
|
|
153
|
+
const isDirectory = Boolean(entry.is_dir);
|
|
154
|
+
const isSelected = normalizePath(instance.state.selectedPath) === normalizePath(entry.path);
|
|
155
|
+
const row = document.createElement("button");
|
|
156
|
+
row.type = "button";
|
|
157
|
+
row.className = `entry ${isDirectory ? "directory" : "file"}${isSelected ? " selected" : ""}`;
|
|
158
|
+
row.dataset.path = entry.path;
|
|
159
|
+
|
|
160
|
+
row.innerHTML = `
|
|
161
|
+
<span class="icon">${isDirectory ? "folder" : "description"}</span>
|
|
162
|
+
<span class="entry-name">${escapeHtml(entry.name)}</span>
|
|
163
|
+
<span class="entry-path">${escapeHtml(entry.path)}</span>
|
|
164
|
+
`;
|
|
165
|
+
|
|
166
|
+
if (isDirectory) {
|
|
167
|
+
row.addEventListener("click", () => {
|
|
168
|
+
instance.state.currentDirectory = normalizePath(entry.path);
|
|
169
|
+
instance.state.selectedPath = "";
|
|
170
|
+
instance.render();
|
|
171
|
+
});
|
|
172
|
+
} else {
|
|
173
|
+
row.addEventListener("click", () => {
|
|
174
|
+
instance.state.selectedPath = normalizePath(entry.path);
|
|
175
|
+
instance.render();
|
|
176
|
+
});
|
|
177
|
+
row.addEventListener("dblclick", () => {
|
|
178
|
+
instance.confirmSelection(normalizePath(entry.path));
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return row;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function createInstance(component) {
|
|
186
|
+
const overlay = ensureOverlay();
|
|
187
|
+
const subtitle = overlay.querySelector(".subtitle");
|
|
188
|
+
const list = overlay.querySelector(".list");
|
|
189
|
+
const upButton = overlay.querySelector(".up-button");
|
|
190
|
+
const directoryInput = overlay.querySelector(".directory-input");
|
|
191
|
+
const selectButton = overlay.querySelector(".select-action");
|
|
192
|
+
const cancelButton = overlay.querySelector(".cancel-action");
|
|
193
|
+
const backdrop = overlay.querySelector(".backdrop");
|
|
194
|
+
|
|
195
|
+
if (!subtitle || !list || !upButton || !directoryInput || !selectButton || !cancelButton || !backdrop) {
|
|
196
|
+
throw new Error("Unexpected: server file picker overlay not found");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const instance = {
|
|
200
|
+
overlay,
|
|
201
|
+
subtitle,
|
|
202
|
+
list,
|
|
203
|
+
upButton,
|
|
204
|
+
directoryInput,
|
|
205
|
+
selectButton,
|
|
206
|
+
cancelButton,
|
|
207
|
+
backdrop,
|
|
208
|
+
treeMap: buildDirectoryMap(component.data.tree),
|
|
209
|
+
state: {
|
|
210
|
+
currentDirectory: normalizePath(component.data.current_directory || "."),
|
|
211
|
+
selectedPath: normalizePath(component.data.selected_path || ""),
|
|
212
|
+
elementType: component.data.element_type || "file",
|
|
213
|
+
topLevelDirectory: component.data.top_level_directory || null,
|
|
214
|
+
targetInputLabel: component.data.target_input_label || null,
|
|
215
|
+
showFiles: Boolean(component.data.show_files),
|
|
216
|
+
showHidden: Boolean(component.data.show_hidden),
|
|
217
|
+
extensions: Array.isArray(component.data.extensions) ? component.data.extensions : null,
|
|
218
|
+
tree: component.data.tree,
|
|
219
|
+
},
|
|
220
|
+
confirmSelection: (selectedPath) => {
|
|
221
|
+
const resolvedPath = normalizePath(selectedPath || instance.state.selectedPath);
|
|
222
|
+
if (!resolvedPath) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
instance.state.selectedPath = resolvedPath;
|
|
227
|
+
updateTextInput(instance.state.targetInputLabel, resolvedPath);
|
|
228
|
+
component.setStateValue("selected_path", resolvedPath);
|
|
229
|
+
component.setTriggerValue("confirm", resolvedPath);
|
|
230
|
+
hideOverlay(instance);
|
|
231
|
+
},
|
|
232
|
+
cancelSelection: () => {
|
|
233
|
+
component.setTriggerValue("cancel", true);
|
|
234
|
+
hideOverlay(instance);
|
|
235
|
+
},
|
|
236
|
+
render: () => {
|
|
237
|
+
instance.subtitle.textContent = instance.state.currentDirectory;
|
|
238
|
+
directoryInput.value = instance.state.currentDirectory;
|
|
239
|
+
upButton.disabled = Boolean(
|
|
240
|
+
instance.state.topLevelDirectory &&
|
|
241
|
+
normalizePath(instance.state.currentDirectory) === normalizePath(instance.state.topLevelDirectory),
|
|
242
|
+
);
|
|
243
|
+
selectButton.disabled = instance.state.elementType !== "directory" && !instance.state.selectedPath;
|
|
244
|
+
|
|
245
|
+
list.innerHTML = "";
|
|
246
|
+
const entries = getCurrentEntries(instance.treeMap, instance.state.currentDirectory);
|
|
247
|
+
|
|
248
|
+
const visibleEntries = entries.filter((entry) => {
|
|
249
|
+
if (!entry) {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (entry.name.startsWith(".") && !instance.state.showHidden) {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (entry.is_dir) {
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (!instance.state.showFiles) {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (instance.state.extensions && instance.state.extensions.length > 0) {
|
|
266
|
+
const suffix = entry.name.toLowerCase().slice(entry.name.lastIndexOf("."));
|
|
267
|
+
return instance.state.extensions.includes(suffix);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return true;
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
if (!visibleEntries.length) {
|
|
274
|
+
const empty = document.createElement("div");
|
|
275
|
+
empty.className = "empty";
|
|
276
|
+
empty.textContent = "No matching items in this directory.";
|
|
277
|
+
list.appendChild(empty);
|
|
278
|
+
} else {
|
|
279
|
+
for (const entry of visibleEntries) {
|
|
280
|
+
list.appendChild(renderEntry(instance, entry));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
upButton.onclick = () => {
|
|
287
|
+
const nextDirectory = parentDirectory(instance.state.currentDirectory);
|
|
288
|
+
if (instance.treeMap.has(normalizePath(nextDirectory))) {
|
|
289
|
+
instance.state.currentDirectory = nextDirectory;
|
|
290
|
+
instance.state.selectedPath = "";
|
|
291
|
+
instance.render();
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
directoryInput.onkeydown = (event) => {
|
|
296
|
+
if (event.key === "Enter") {
|
|
297
|
+
event.preventDefault();
|
|
298
|
+
const nextDirectory = normalizePath(directoryInput.value.trim() || ".");
|
|
299
|
+
if (instance.treeMap.has(nextDirectory)) {
|
|
300
|
+
instance.state.currentDirectory = nextDirectory;
|
|
301
|
+
instance.state.selectedPath = "";
|
|
302
|
+
instance.render();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (event.key === "Escape") {
|
|
306
|
+
event.preventDefault();
|
|
307
|
+
instance.cancelSelection();
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
directoryInput.onblur = () => {
|
|
312
|
+
directoryInput.value = instance.state.currentDirectory;
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
selectButton.onclick = () => {
|
|
316
|
+
const finalPath = instance.state.elementType === "directory" ? instance.state.currentDirectory : instance.state.selectedPath;
|
|
317
|
+
if (finalPath) {
|
|
318
|
+
instance.confirmSelection(finalPath);
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
cancelButton.onclick = () => {
|
|
323
|
+
instance.cancelSelection();
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
backdrop.onclick = () => {
|
|
327
|
+
instance.cancelSelection();
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
if (!window.__serverFilePickerEscapeHandler) {
|
|
331
|
+
window.__serverFilePickerEscapeHandler = (event) => {
|
|
332
|
+
if (event.key === "Escape" && overlay.style.display !== "none") {
|
|
333
|
+
event.preventDefault();
|
|
334
|
+
instance.cancelSelection();
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
window.addEventListener("keydown", window.__serverFilePickerEscapeHandler);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
instance.render();
|
|
341
|
+
return instance;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function showOverlay(instance) {
|
|
345
|
+
instance.overlay.style.display = "grid";
|
|
346
|
+
instance.overlay.setAttribute("aria-hidden", "false");
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function hideOverlay(instance) {
|
|
350
|
+
instance.overlay.style.display = "none";
|
|
351
|
+
instance.overlay.setAttribute("aria-hidden", "true");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export default function(component) {
|
|
355
|
+
const instanceKey = component.key || "server-file-picker-default";
|
|
356
|
+
let instance = instances.get(instanceKey);
|
|
357
|
+
|
|
358
|
+
if (!instance) {
|
|
359
|
+
instance = createInstance(component);
|
|
360
|
+
instances.set(instanceKey, instance);
|
|
361
|
+
} else {
|
|
362
|
+
instance.treeMap = buildDirectoryMap(component.data.tree);
|
|
363
|
+
instance.state = {
|
|
364
|
+
currentDirectory: normalizePath(component.data.current_directory || "."),
|
|
365
|
+
selectedPath: normalizePath(component.data.selected_path || ""),
|
|
366
|
+
elementType: component.data.element_type || "file",
|
|
367
|
+
topLevelDirectory: component.data.top_level_directory || null,
|
|
368
|
+
targetInputLabel: component.data.target_input_label || null,
|
|
369
|
+
showFiles: Boolean(component.data.show_files),
|
|
370
|
+
showHidden: Boolean(component.data.show_hidden),
|
|
371
|
+
extensions: Array.isArray(component.data.extensions) ? component.data.extensions : null,
|
|
372
|
+
tree: component.data.tree,
|
|
373
|
+
};
|
|
374
|
+
instance.render();
|
|
375
|
+
showOverlay(instance);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
showOverlay(instance);
|
|
379
|
+
|
|
380
|
+
return () => {
|
|
381
|
+
// Keep the overlay alive across Streamlit reruns; it is explicitly closed
|
|
382
|
+
// by the cancel/select actions.
|
|
383
|
+
};
|
|
384
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
streamlit_server_file_picker/__init__.py
|
|
4
|
+
streamlit_server_file_picker.egg-info/PKG-INFO
|
|
5
|
+
streamlit_server_file_picker.egg-info/SOURCES.txt
|
|
6
|
+
streamlit_server_file_picker.egg-info/dependency_links.txt
|
|
7
|
+
streamlit_server_file_picker.egg-info/requires.txt
|
|
8
|
+
streamlit_server_file_picker.egg-info/top_level.txt
|
|
9
|
+
streamlit_server_file_picker/component/__init__.py
|
|
10
|
+
streamlit_server_file_picker/component/frontend/component.css
|
|
11
|
+
streamlit_server_file_picker/component/frontend/component.html
|
|
12
|
+
streamlit_server_file_picker/component/frontend/component.js
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
streamlit
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
streamlit_server_file_picker
|