Skip to content

Commit 03b20ee

Browse files
committed
Add path selection search, hyperlink-aware previews and better .docx/.pptx preview
1 parent c24935a commit 03b20ee

5 files changed

Lines changed: 504 additions & 77 deletions

File tree

CLAUDE.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## What this app does
6+
7+
FSS Navigator App is a local Streamlit application for browsing computational research project folders. It renders a tree-style directory explorer on the left and a file preview panel on the right. All file access is local — no files leave the machine.
8+
9+
## Running the app
10+
11+
From any project folder you want to inspect:
12+
13+
```bash
14+
python /path/to/FSS-Navigator-App/run_app.py
15+
```
16+
17+
`run_app.py` resolves the app path relative to itself and launches `streamlit run Computations/Code/app/app.py`.
18+
19+
The app uses `Path.cwd()` as the default project directory, so launch location matters.
20+
21+
## Running tests
22+
23+
Tests live in `Computations/Code/tests/` and are run with pytest from the repo root:
24+
25+
```bash
26+
pytest Computations/Code/tests
27+
```
28+
29+
To run a single test file:
30+
31+
```bash
32+
pytest Computations/Code/tests/test_previewer.py
33+
```
34+
35+
`conftest.py` adds `Computations/Code/` to `sys.path` so `core.*` imports resolve without package installation.
36+
37+
## Environment setup
38+
39+
The conda environment is named `fssnav`:
40+
41+
```bash
42+
conda env create -f environment.yml
43+
conda activate fssnav
44+
```
45+
46+
Or install via pip:
47+
48+
```bash
49+
pip install -r requirements.txt
50+
```
51+
52+
## Architecture
53+
54+
```
55+
run_app.py ← entry point, launches streamlit
56+
Computations/Code/
57+
app/app.py ← full Streamlit UI (single file)
58+
core/
59+
tree_builder.py ← list_directory(), search_directory()
60+
previewer.py ← get_file_type() + per-format readers
61+
tests/
62+
conftest.py ← sys.path setup for core imports
63+
test_tree_builder.py
64+
test_previewer.py
65+
Data/demo_project/ ← sample project for manual testing
66+
```
67+
68+
**`app.py`** owns all Streamlit state (`session_state.project_path`, `session_state.selected_path`, `session_state.expanded_dirs`) and all UI rendering. It calls into `core/` for filesystem logic and file reading.
69+
70+
**`tree_builder.py`** filters out dotfiles, caches, and tool-specific directories via `IGNORED_DIRECTORIES` and `IGNORED_FILES` sets. `list_directory()` returns folders before files, both sorted case-insensitively.
71+
72+
**`previewer.py`** maps file extensions to a preview type string (`"text"`, `"table"`, `"image"`, `"pdf"`, `"notebook"`, `"docx"`, `"pptx"`, `"unsupported"`), then each reader function handles its type. PDF rendering uses PyMuPDF (`fitz`); notebooks use `nbconvert` HTMLExporter.
73+
74+
## Key constraints
75+
76+
- No external server calls — this is a privacy-first local tool.
77+
- `app.py` uses `st.rerun()` after every tree interaction; keep state mutations before `st.rerun()` calls, never after.
78+
- Adding a new file type requires changes in two places: `TEXT_EXTENSIONS`/`TABLE_EXTENSIONS`/etc. in `previewer.py` AND the preview dispatch block in `app.py`.

Computations/Code/app/app.py

Lines changed: 99 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,21 @@
1111
import sys
1212
from pathlib import Path
1313
import streamlit as st
14-
import streamlit.components.v1 as components
1514

1615
CURRENT_FILE = Path(__file__).resolve()
1716
CODE_DIR = CURRENT_FILE.parents[1]
1817
sys.path.append(str(CODE_DIR))
1918

20-
from core.tree_builder import list_directory # noqa: E402
19+
from core.tree_builder import list_directory, search_directory # noqa: E402
2120
from core.previewer import ( # noqa: E402
2221
get_file_type,
2322
read_text_file,
2423
read_table_file,
2524
render_notebook_html,
2625
render_pdf_pages,
27-
read_docx_file,
28-
read_pptx_file,
26+
render_docx_as_html,
27+
render_pptx_as_html,
28+
extract_pdf_links,
2929
)
3030

3131
st.set_page_config(
@@ -208,13 +208,54 @@
208208
background: #94a3b8;
209209
}
210210
211+
/* Input labels */
212+
.stTextInput label,
213+
div[data-testid="stTextInputRootElement"] label {
214+
color: #374151 !important;
215+
font-size: 0.85rem !important;
216+
}
217+
211218
</style>
212219
""",
213220
unsafe_allow_html=True,
214221
)
215222

223+
st.markdown(
224+
f"""
225+
<div style="
226+
margin-bottom: 0.6rem;
227+
padding-left: 0.1rem;
228+
">
229+
<h1 style="
230+
margin-bottom:0rem;
231+
">
232+
🧭 FSS Navigator
233+
</h1>
234+
</div>
235+
""",
236+
unsafe_allow_html=True,
237+
)
238+
239+
default_project_path = Path.cwd().resolve()
240+
241+
if "project_path" not in st.session_state:
242+
st.session_state.project_path = str(default_project_path)
243+
244+
project_path_input = st.text_input(
245+
"Project directory to inspect",
246+
value=st.session_state.project_path,
247+
placeholder="/path/to/project",
248+
label_visibility="collapsed",
249+
)
250+
251+
project_path = Path(project_path_input).expanduser().resolve()
252+
253+
if not project_path.exists() or not project_path.is_dir():
254+
st.error("The provided path does not exist or is not a directory.")
255+
st.stop()
256+
257+
st.session_state.project_path = str(project_path)
216258

217-
project_path = Path.cwd().resolve()
218259

219260
if "selected_path" not in st.session_state:
220261
st.session_state.selected_path = None
@@ -275,35 +316,48 @@ def render_explorer(path: Path, level: int = 0, prefix: str = "") -> None:
275316
st.rerun()
276317

277318

278-
st.markdown(
279-
f"""
280-
<div style="
281-
margin-bottom: 0.6rem;
282-
padding-left: 0.1rem;
283-
">
284-
<h1 style="
285-
margin-bottom:0rem;
286-
">
287-
🧭 FSS Navigator
288-
</h1>
289-
</div>
290-
""",
291-
unsafe_allow_html=True,
292-
)
293319

294320
left_col, right_col = st.columns([1.2, 3], gap="small")
295321

296322
with left_col:
297323
left_panel = st.container(key="left_panel")
298324
with left_panel:
299325
st.markdown("### 📂 Directory structure")
300-
render_explorer(project_path)
326+
327+
search_query = st.text_input(
328+
"Search in selected directory",
329+
placeholder="Search files or folders...",
330+
key="directory_search",
331+
label_visibility="collapsed",
332+
)
333+
334+
if search_query:
335+
search_results = search_directory(project_path, search_query)
336+
337+
st.caption(f"{len(search_results)} result(s) found")
338+
339+
for result in search_results[:100]:
340+
icon = "📁" if result.is_dir() else "📄"
341+
relative_result = result.relative_to(project_path)
342+
343+
if st.button(
344+
f"{icon} {relative_result}",
345+
key=f"search_{result}",
346+
use_container_width=True,
347+
):
348+
st.session_state.selected_path = str(result)
349+
st.rerun()
350+
351+
if len(search_results) > 100:
352+
st.warning("Showing first 100 results only.")
353+
else:
354+
render_explorer(project_path)
301355

302356
with right_col:
303357
right_panel = st.container(key="right_panel")
304358
with right_panel:
305359
st.markdown("### 👁️ Preview panel")
306-
360+
307361
selected_path_value = st.session_state.selected_path
308362

309363
if selected_path_value is None:
@@ -346,11 +400,11 @@ def render_explorer(path: Path, level: int = 0, prefix: str = "") -> None:
346400

347401
if suffix in ["md", "rmd"]:
348402
content = read_text_file(str(selected_path), max_chars=None)
349-
st.markdown(content, unsafe_allow_html=False)
403+
st.markdown(content, unsafe_allow_html=True)
350404

351405
elif suffix == "html":
352406
content = read_text_file(str(selected_path), max_chars=None)
353-
components.html(content, height=800, scrolling=True)
407+
st.html(content)
354408

355409
elif suffix in [
356410
"py",
@@ -383,32 +437,44 @@ def render_explorer(path: Path, level: int = 0, prefix: str = "") -> None:
383437
for page_number, image in pdf_pages:
384438
st.write(f"Page {page_number}")
385439
st.image(image, width="stretch")
440+
441+
pdf_links = extract_pdf_links(str(selected_path))
442+
443+
if pdf_links:
444+
st.write("### Links detected in PDF")
445+
446+
for link in pdf_links:
447+
page_label = f"Page {link['page']}"
448+
449+
if link["uri"]:
450+
st.markdown(f"- {page_label}: [{link['uri']}]({link['uri']})")
451+
452+
elif link["target_page"] is not None:
453+
st.markdown(
454+
f"- {page_label}: internal PDF link to page {link['target_page'] + 1}"
455+
)
386456

387457
except Exception as error:
388458
st.error(f"Could not render PDF: {error}")
389459

390460
elif preview_type == "notebook":
391461
try:
392462
notebook_html = render_notebook_html(str(selected_path))
393-
components.html(notebook_html, height=900, scrolling=True)
463+
st.html(notebook_html)
394464
except Exception as error:
395465
st.error(f"Could not render notebook: {error}")
396466

397467
elif preview_type == "docx":
398468
try:
399-
docx_text = read_docx_file(str(selected_path))
400-
st.text_area(
401-
"DOCX content",
402-
value=docx_text,
403-
height=800,
404-
)
469+
docx_html = render_docx_as_html(str(selected_path))
470+
st.html(docx_html)
405471
except Exception as error:
406472
st.error(f"Could not preview DOCX file: {error}")
407473

408474
elif preview_type == "pptx":
409475
try:
410-
pptx_text = read_pptx_file(str(selected_path))
411-
st.markdown(pptx_text)
476+
pptx_html = render_pptx_as_html(str(selected_path))
477+
st.html(pptx_html)
412478
except Exception as error:
413479
st.error(f"Could not preview PPTX file: {error}")
414480

0 commit comments

Comments
 (0)