AdithyaSK HF Staff commited on
Commit
e265a14
Β·
1 Parent(s): e3c4a23

v0.3: fix Gradio 6 Radio tuple order + indented file-tree with folder rows

Browse files

Two bugs visible in v0.2 screenshots:

1. ERROR popup ("Value: '__overview__'") + "no <file> in this task" appearing
even when the file is in the tree. Root cause: Gradio 6 expects Radio
choices as `(label, value)` tuples; I had `(value, label)`. So the radio
was returning labels back as the "value" (e.g. "πŸ“ environment/\n └─
Dockerfile" instead of "environment/Dockerfile"), no dispatch branch
matched, and the content lookup returned None.

2. File tree displayed as a flat list with no indentation. Wanted proper
file-explorer feel with folder hierarchy.

Fixes:
- Flip all Radio choice tuples to `(label, value)` order
- New `_build_file_tree(task)` produces folder pseudo-rows ("πŸ“‚ solution/")
plus indented children (" β”œβ”€ patch.diff", " └─ solve.sh") with
unicode tree glyphs
- Folder pseudo-rows route to their first present child via a state-stored
redirect map β€” click "πŸ“‚ solution/" β†’ opens patch.diff, radio selection
updates to show which file was actually opened
- Folder rows only appear when β‰₯1 of their children exists on the task
- Pre-filter top-level files (task.toml, instruction.md) on actual presence

Files changed (1) hide show
  1. app.py +98 -49
app.py CHANGED
@@ -40,18 +40,9 @@ logger = logging.getLogger("harbor-visualiser")
40
  # Virtual entry id for the metadata overview (not a real file).
41
  _OVERVIEW = "__overview__"
42
 
43
- # (file_id, display label, is_markdown) β€” file_id is either the relative path
44
- # from the task root OR the special _OVERVIEW sentinel. Display labels use
45
- # unicode tree glyphs so the radio looks file-explorer-like.
46
- _FILE_TREE_ENTRIES: list[tuple[str, str]] = [
47
- (_OVERVIEW, "β“˜ Overview"),
48
- ("task.toml", "πŸ“„ task.toml"),
49
- ("instruction.md", "πŸ“„ instruction.md"),
50
- ("solution/patch.diff", "πŸ“ solution/\n └─ patch.diff"),
51
- ("solution/solve.sh", " └─ solve.sh"),
52
- ("tests/test.sh", "πŸ“ tests/\n └─ test.sh"),
53
- ("environment/Dockerfile", "πŸ“ environment/\n └─ Dockerfile"),
54
- ]
55
 
56
  # Map suffix β†’ Gradio Code language token. Everything else falls back to "shell".
57
  _EXTENSION_LANGUAGE: dict[str, str] = {
@@ -71,19 +62,6 @@ def _file_language(filename: str) -> str:
71
  return _EXTENSION_LANGUAGE.get(suffix, "shell")
72
 
73
 
74
- def _list_present_files(task: HarborTask) -> list[tuple[str, str]]:
75
- """Return (file_id, display_label) tuples for every _FILE_TREE_ENTRIES row
76
- that exists on this task. Overview is always first."""
77
- present: list[tuple[str, str]] = []
78
- for file_id, label in _FILE_TREE_ENTRIES:
79
- if file_id == _OVERVIEW:
80
- present.append((file_id, label))
81
- continue
82
- if _read_task_file(task, file_id) is not None:
83
- present.append((file_id, label))
84
- return present
85
-
86
-
87
  def _read_task_file(task: HarborTask, file_id: str) -> str | None:
88
  """Fetch the content for a file_id; None when the file isn't present."""
89
  if file_id == "task.toml":
@@ -101,6 +79,56 @@ def _read_task_file(task: HarborTask, file_id: str) -> str | None:
101
  return None
102
 
103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  # ---------------------------------------------------------------------------
105
  # Backend handlers
106
  # ---------------------------------------------------------------------------
@@ -110,7 +138,7 @@ def load_dataset_action(uri: str):
110
  """Top-level "Load" button handler.
111
 
112
  Outputs (in order):
113
- status_md, source_state, root_state, all_tasks_state,
114
  task_search_value, task_radio_update,
115
  file_radio_update, markdown_update, code_update
116
  """
@@ -137,7 +165,7 @@ def load_dataset_action(uri: str):
137
 
138
  first = tasks[0]
139
  task = load_task(root, first)
140
- file_choices = _list_present_files(task)
141
  md_html, code_update = _render_file(task, _OVERVIEW)
142
  status = (
143
  f"βœ… Loaded **{source.display}** β€” {len(tasks)} task"
@@ -147,7 +175,8 @@ def load_dataset_action(uri: str):
147
  status,
148
  source.display,
149
  str(root),
150
- tasks, # cached "all tasks" β€” used by the search filter
 
151
  "", # clear search box
152
  gr.update(choices=tasks, value=first, label=f"Tasks ({len(tasks)})"),
153
  gr.update(choices=file_choices, value=_OVERVIEW, label="Files"),
@@ -159,30 +188,57 @@ def load_dataset_action(uri: str):
159
  def select_task_action(task_id: str, root: str):
160
  """Switch task β†’ repopulate file tree, render the Overview."""
161
  if not task_id or not root:
162
- return _render_no_task()
 
 
 
 
 
163
  try:
164
  task = load_task(Path(root), task_id)
165
  except Exception as exc:
166
  logger.exception("load_task failed")
167
- return _render_no_task(error=str(exc))
168
- file_choices = _list_present_files(task)
 
 
 
 
 
169
  md_html, code_update = _render_file(task, _OVERVIEW)
170
  return (
 
171
  gr.update(choices=file_choices, value=_OVERVIEW, label="Files"),
172
  md_html,
173
  code_update,
174
  )
175
 
176
 
177
- def select_file_action(file_id: str, root: str, task_id: str):
178
- """Switch file inside a task β†’ render its content."""
 
 
 
 
 
179
  if not file_id or not root or not task_id:
180
- return ("Pick a task first.", gr.update(value="", visible=False))
 
 
 
 
 
 
 
 
 
181
  try:
182
  task = load_task(Path(root), task_id)
183
  except Exception as exc:
184
- return (f"❌ {exc}", gr.update(value="", visible=False))
185
- return _render_file(task, file_id)
 
 
186
 
187
 
188
  def filter_tasks_action(query: str, all_tasks: list[str], current_root: str):
@@ -221,6 +277,7 @@ def _empty_state(status: str):
221
  "", # source_state
222
  "", # root_state
223
  [], # all_tasks_state
 
224
  "", # task search clear
225
  gr.update(choices=[], value=None, label="Tasks"),
226
  gr.update(choices=[], value=None, label="Files"),
@@ -229,16 +286,6 @@ def _empty_state(status: str):
229
  )
230
 
231
 
232
- def _render_no_task(error: str | None = None):
233
- """Used by select_task_action when no task is selected."""
234
- msg = error or "Pick a task from the list."
235
- return (
236
- gr.update(choices=[], value=None, label="Files"),
237
- msg,
238
- gr.update(value="", visible=False),
239
- )
240
-
241
-
242
  def _render_file(task: HarborTask, file_id: str):
243
  """Return (markdown_html, code_update) for the right-panel content area.
244
 
@@ -400,6 +447,7 @@ def build_ui() -> gr.Blocks:
400
  source_state = gr.State("")
401
  root_state = gr.State("")
402
  all_tasks_state = gr.State([]) # full unfiltered list for the search box
 
403
 
404
  # ─── 3-column file-explorer layout ────────────────────────────────
405
  with gr.Row():
@@ -447,6 +495,7 @@ def build_ui() -> gr.Blocks:
447
  source_state,
448
  root_state,
449
  all_tasks_state,
 
450
  task_search,
451
  task_list,
452
  file_tree,
@@ -468,13 +517,13 @@ def build_ui() -> gr.Blocks:
468
  task_list.change(
469
  fn=select_task_action,
470
  inputs=[task_list, root_state],
471
- outputs=[file_tree, content_md, content_code],
472
  )
473
 
474
  file_tree.change(
475
  fn=select_file_action,
476
- inputs=[file_tree, root_state, task_list],
477
- outputs=[content_md, content_code],
478
  )
479
 
480
  task_search.change(
 
40
  # Virtual entry id for the metadata overview (not a real file).
41
  _OVERVIEW = "__overview__"
42
 
43
+ # Folder pseudo-ids β€” selecting one routes to its first present child.
44
+ _FOLDER_PREFIX = "__folder__"
45
+
 
 
 
 
 
 
 
 
 
46
 
47
  # Map suffix β†’ Gradio Code language token. Everything else falls back to "shell".
48
  _EXTENSION_LANGUAGE: dict[str, str] = {
 
62
  return _EXTENSION_LANGUAGE.get(suffix, "shell")
63
 
64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  def _read_task_file(task: HarborTask, file_id: str) -> str | None:
66
  """Fetch the content for a file_id; None when the file isn't present."""
67
  if file_id == "task.toml":
 
79
  return None
80
 
81
 
82
+ def _build_file_tree(task: HarborTask) -> tuple[list[tuple[str, str]], dict[str, str]]:
83
+ """Render a file-explorer-style choice list for a task.
84
+
85
+ Returns:
86
+ choices: list of (label, value) tuples for `gr.Radio`. Labels use unicode
87
+ tree glyphs (πŸ“‚, β”œβ”€, └─) so the radio reads like a file tree.
88
+ folder_redirects: maps each folder pseudo-id to its first present child
89
+ file_id so clicking a folder header opens its first file.
90
+
91
+ Order is fixed: Overview first, then top-level files, then folders with
92
+ their children. Folders only appear when they have β‰₯1 present child.
93
+ """
94
+ choices: list[tuple[str, str]] = [("β“˜ Overview", _OVERVIEW)]
95
+ redirects: dict[str, str] = {}
96
+
97
+ # Top-level files (always under root)
98
+ if (task.task_toml_raw or "").strip():
99
+ choices.append(("πŸ“„ task.toml", "task.toml"))
100
+ if (task.instruction_md or task.instruction_inline):
101
+ choices.append(("πŸ“„ instruction.md", "instruction.md"))
102
+
103
+ def _maybe_folder(folder: str, children: list[tuple[str, str]]) -> None:
104
+ """Append `πŸ“‚ folder/` + indented children, only if children non-empty.
105
+
106
+ `children` is a list of (basename, full_file_id) tuples.
107
+ """
108
+ present = [(b, fid) for (b, fid) in children if _read_task_file(task, fid) is not None]
109
+ if not present:
110
+ return
111
+ folder_id = f"{_FOLDER_PREFIX}{folder}"
112
+ choices.append((f"πŸ“‚ {folder}/", folder_id))
113
+ redirects[folder_id] = present[0][1] # folder header β†’ first child
114
+ for i, (basename, full_id) in enumerate(present):
115
+ glyph = "└─" if i == len(present) - 1 else "β”œβ”€"
116
+ choices.append((f" {glyph} {basename}", full_id))
117
+
118
+ _maybe_folder("solution", [
119
+ ("patch.diff", "solution/patch.diff"),
120
+ ("solve.sh", "solution/solve.sh"),
121
+ ])
122
+ _maybe_folder("tests", [
123
+ ("test.sh", "tests/test.sh"),
124
+ ])
125
+ _maybe_folder("environment", [
126
+ ("Dockerfile", "environment/Dockerfile"),
127
+ ])
128
+
129
+ return choices, redirects
130
+
131
+
132
  # ---------------------------------------------------------------------------
133
  # Backend handlers
134
  # ---------------------------------------------------------------------------
 
138
  """Top-level "Load" button handler.
139
 
140
  Outputs (in order):
141
+ status_md, source_state, root_state, all_tasks_state, folder_redirects_state,
142
  task_search_value, task_radio_update,
143
  file_radio_update, markdown_update, code_update
144
  """
 
165
 
166
  first = tasks[0]
167
  task = load_task(root, first)
168
+ file_choices, redirects = _build_file_tree(task)
169
  md_html, code_update = _render_file(task, _OVERVIEW)
170
  status = (
171
  f"βœ… Loaded **{source.display}** β€” {len(tasks)} task"
 
175
  status,
176
  source.display,
177
  str(root),
178
+ tasks,
179
+ redirects,
180
  "", # clear search box
181
  gr.update(choices=tasks, value=first, label=f"Tasks ({len(tasks)})"),
182
  gr.update(choices=file_choices, value=_OVERVIEW, label="Files"),
 
188
  def select_task_action(task_id: str, root: str):
189
  """Switch task β†’ repopulate file tree, render the Overview."""
190
  if not task_id or not root:
191
+ return (
192
+ {},
193
+ gr.update(choices=[], value=None, label="Files"),
194
+ "Pick a task from the list.",
195
+ gr.update(value="", visible=False),
196
+ )
197
  try:
198
  task = load_task(Path(root), task_id)
199
  except Exception as exc:
200
  logger.exception("load_task failed")
201
+ return (
202
+ {},
203
+ gr.update(choices=[], value=None, label="Files"),
204
+ f"❌ {exc}",
205
+ gr.update(value="", visible=False),
206
+ )
207
+ file_choices, redirects = _build_file_tree(task)
208
  md_html, code_update = _render_file(task, _OVERVIEW)
209
  return (
210
+ redirects,
211
  gr.update(choices=file_choices, value=_OVERVIEW, label="Files"),
212
  md_html,
213
  code_update,
214
  )
215
 
216
 
217
+ def select_file_action(file_id: str, root: str, task_id: str, folder_redirects: dict):
218
+ """Switch file inside a task β†’ render its content.
219
+
220
+ Folder pseudo-ids are routed to their first child via `folder_redirects`.
221
+ The file_tree radio's value is also updated so the user sees which file
222
+ was opened.
223
+ """
224
  if not file_id or not root or not task_id:
225
+ return ("Pick a task first.", gr.update(value="", visible=False), gr.update())
226
+
227
+ # Folder header click β†’ redirect to first child + update radio selection
228
+ redirect = (folder_redirects or {}).get(file_id)
229
+ if redirect is not None:
230
+ file_id = redirect
231
+ radio_update = gr.update(value=file_id)
232
+ else:
233
+ radio_update = gr.update() # no-op for normal file clicks
234
+
235
  try:
236
  task = load_task(Path(root), task_id)
237
  except Exception as exc:
238
+ return (f"❌ {exc}", gr.update(value="", visible=False), gr.update())
239
+
240
+ md_html, code_update = _render_file(task, file_id)
241
+ return (md_html, code_update, radio_update)
242
 
243
 
244
  def filter_tasks_action(query: str, all_tasks: list[str], current_root: str):
 
277
  "", # source_state
278
  "", # root_state
279
  [], # all_tasks_state
280
+ {}, # folder_redirects_state
281
  "", # task search clear
282
  gr.update(choices=[], value=None, label="Tasks"),
283
  gr.update(choices=[], value=None, label="Files"),
 
286
  )
287
 
288
 
 
 
 
 
 
 
 
 
 
 
289
  def _render_file(task: HarborTask, file_id: str):
290
  """Return (markdown_html, code_update) for the right-panel content area.
291
 
 
447
  source_state = gr.State("")
448
  root_state = gr.State("")
449
  all_tasks_state = gr.State([]) # full unfiltered list for the search box
450
+ folder_redirects_state = gr.State({}) # folder pseudo-id β†’ first child file_id
451
 
452
  # ─── 3-column file-explorer layout ────────────────────────────────
453
  with gr.Row():
 
495
  source_state,
496
  root_state,
497
  all_tasks_state,
498
+ folder_redirects_state,
499
  task_search,
500
  task_list,
501
  file_tree,
 
517
  task_list.change(
518
  fn=select_task_action,
519
  inputs=[task_list, root_state],
520
+ outputs=[folder_redirects_state, file_tree, content_md, content_code],
521
  )
522
 
523
  file_tree.change(
524
  fn=select_file_action,
525
+ inputs=[file_tree, root_state, task_list, folder_redirects_state],
526
+ outputs=[content_md, content_code, file_tree],
527
  )
528
 
529
  task_search.change(