Skip to content

Commit 78ab590

Browse files
committed
Viewer: Support recursive folders
1 parent 68a7b92 commit 78ab590

8 files changed

Lines changed: 686 additions & 58 deletions

File tree

.cursor/rules/viewer.mdc

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ two AI agent in a round-based game against each other.
1414
* JS should be kept to a minimum
1515
* Your input is a game folder (described below)
1616
* Use clean modern CSS, but don't go too crazy, simplicity is key
17-
* This is a single page application
17+
* CSS and JS should be in separate static files, not inline in templates
18+
* The application consists of two main pages: the viewer and the game picker
1819

1920
## Input folder
2021

@@ -78,12 +79,71 @@ Here's how a `.traj.log` file looks:
7879
}
7980
```
8081

81-
## Page design
82+
## Application Structure
83+
84+
The application has two main pages:
85+
86+
1. **Game Picker** (`/picker`): A hierarchical tree view of all available game sessions
87+
2. **Trajectory Viewer** (`/`): The main viewer for a selected game session
88+
89+
### Navigation
90+
91+
* The viewer has a "Pick Game" button that navigates to the picker
92+
* The picker shows all game sessions in a tree structure with "Open" and "Open in new tab" buttons
93+
* Navigation between pages happens in the same tab by default
94+
95+
### Keyboard Shortcuts
96+
97+
* `p` (lowercase): Open game picker in same tab (from viewer)
98+
* `P` (uppercase): Open game picker in new tab (from viewer)
99+
* `Ctrl/Cmd + D`: Toggle dark/light mode
100+
* `Ctrl/Cmd + E`: Expand all sections
101+
* `Ctrl/Cmd + Shift + E`: Collapse all sections
102+
* `Escape`: Close all open sections
103+
104+
### Mouse Navigation
105+
106+
* **Left-click**: Default action (navigate in same tab)
107+
* **Middle-click**: Open in new tab (works on all buttons and game rows)
108+
* **Ctrl+click or Cmd+click**: Open in new tab (alternative to middle-click)
109+
110+
## Game Picker Design (`/picker`)
111+
112+
### Features
113+
114+
* **Hierarchical Tree Structure**: Shows all folders recursively with proper indentation
115+
* **Game Detection**: Any folder containing `metadata.json` is considered a game folder
116+
* **Folder Collapse/Expand**: Click intermediate folders to minimize/expand their contents
117+
- Collapsed folders show 📂 icon and blue accent border
118+
- Expanded folders show 📁 icon
119+
- Children of collapsed folders are hidden
120+
* **Visual Distinction**:
121+
- Game folders: 🎮 icon, clickable, blue accent color
122+
- Intermediate folders: 📁/📂 icon, clickable to collapse, muted appearance
123+
* **Tree Indentation**: Uses spaces and tree characters (└─) to show folder hierarchy
124+
* **Round Count Display**: Shows number of rounds for each game session
125+
* **Multiple Navigation Options**:
126+
- Click game row: Navigate to viewer (middle-click for new tab)
127+
- "Open" button: Navigate to viewer (supports middle-click)
128+
- "↗" button: Always open viewer in new tab
129+
130+
### Layout
131+
132+
* Clean table structure with three columns: Game Session, Rounds, Action
133+
* Responsive design that adapts to mobile screens
134+
* Hover effects and visual feedback
135+
* Base directory path display at the top
136+
137+
## Trajectory Viewer Design (`/`)
82138

83139
### Header
84140

85-
* Here's some control elements to choose the input folder or visualization settings
86-
* There's a dark/light mode switch
141+
* **"Pick Game" button**: Navigate to picker (supports middle-click for new tab)
142+
- Shows `p` keyboard shortcut indicator
143+
- Tooltip explains middle-click functionality
144+
* **"↗" button**: Always open picker in new tab
145+
* **Dark/light mode toggle button**
146+
* **Current folder path display** with copy functionality
87147

88148
### Main body
89149

codeclash/viewer/app.py

Lines changed: 91 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from pathlib import Path
1111
from typing import Any
1212

13-
from flask import Flask, jsonify, render_template, request
13+
from flask import Flask, jsonify, redirect, render_template, request, url_for
1414

1515
# Global variable to store the directory to search for logs
1616
LOG_BASE_DIR = Path.cwd() / "logs"
@@ -22,10 +22,10 @@ def set_log_base_directory(directory: str | Path):
2222
LOG_BASE_DIR = Path(directory).resolve()
2323

2424

25-
def is_probably_failed_run(log_dir: Path) -> bool:
26-
"""Check if a run probably failed by checking if metadata.json is missing"""
25+
def is_game_folder(log_dir: Path) -> bool:
26+
"""Check if a directory contains metadata.json and is therefore a game folder"""
2727
metadata_file = log_dir / "metadata.json"
28-
return not metadata_file.exists()
28+
return metadata_file.exists()
2929

3030

3131
def get_round_count_from_metadata(log_dir: Path) -> int | None:
@@ -41,6 +41,73 @@ def get_round_count_from_metadata(log_dir: Path) -> int | None:
4141
return None
4242

4343

44+
def find_all_game_folders(base_dir: Path) -> list[dict[str, Any]]:
45+
"""Recursively find all folders and mark which ones contain metadata.json"""
46+
all_folders = []
47+
game_folders = set() # Track which folders are actual game folders
48+
49+
def scan_directory(directory: Path, relative_path: str = ""):
50+
if not directory.exists() or not directory.is_dir():
51+
return
52+
53+
try:
54+
for item in directory.iterdir():
55+
if item.is_dir():
56+
current_relative = relative_path + "/" + item.name if relative_path else item.name
57+
58+
depth = current_relative.count("/")
59+
60+
# Check if this directory is a game folder
61+
if is_game_folder(item):
62+
round_count = get_round_count_from_metadata(item)
63+
game_folders.add(current_relative)
64+
all_folders.append(
65+
{
66+
"name": current_relative,
67+
"full_path": str(item),
68+
"round_count": round_count,
69+
"is_game": True,
70+
"depth": depth,
71+
"parent": relative_path if relative_path else None,
72+
}
73+
)
74+
else:
75+
# Add as intermediate folder if it has game folders in subdirectories
76+
all_folders.append(
77+
{
78+
"name": current_relative,
79+
"full_path": str(item),
80+
"round_count": None,
81+
"is_game": False,
82+
"depth": depth,
83+
"parent": relative_path if relative_path else None,
84+
}
85+
)
86+
87+
# Recursively scan subdirectories
88+
scan_directory(item, current_relative)
89+
except (PermissionError, OSError):
90+
# Skip directories we can't access
91+
pass
92+
93+
scan_directory(base_dir)
94+
95+
# Filter out intermediate folders that don't lead to any game folders
96+
filtered_folders = []
97+
for folder in sorted(all_folders, key=lambda x: x["name"]):
98+
if folder["is_game"]:
99+
# Always include game folders
100+
filtered_folders.append(folder)
101+
else:
102+
# Include intermediate folders only if they have game folders as descendants
103+
folder_path = folder["name"]
104+
has_game_descendants = any(game_path.startswith(folder_path + "/") for game_path in game_folders)
105+
if has_game_descendants:
106+
filtered_folders.append(folder)
107+
108+
return filtered_folders
109+
110+
44111
@dataclass
45112
class GameMetadata:
46113
"""Metadata about a game session"""
@@ -245,33 +312,21 @@ def unescape_content(value):
245312

246313
@app.route("/")
247314
def index():
248-
"""Main viewer page"""
249-
# Get available log directories
250-
logs_dir = LOG_BASE_DIR
251-
log_folders_info = []
252-
if logs_dir.exists():
253-
for d in logs_dir.iterdir():
254-
if d.is_dir():
255-
folder_info = {
256-
"name": d.name,
257-
"is_failed": is_probably_failed_run(d),
258-
"round_count": get_round_count_from_metadata(d),
259-
}
260-
log_folders_info.append(folder_info)
261-
262-
# Sort folders alphabetically by name
263-
log_folders_info.sort(key=lambda x: x["name"])
315+
"""Main viewer page - now redirects to picker if no folder is selected"""
316+
selected_folder = request.args.get("folder")
264317

265-
# Extract just the names for backwards compatibility
266-
log_folders = [folder["name"] for folder in log_folders_info]
318+
if not selected_folder:
319+
return redirect(url_for("game_picker"))
267320

268-
selected_folder = request.args.get("folder", log_folders[0] if log_folders else None)
321+
# Validate the selected folder exists and is a game folder
322+
logs_dir = LOG_BASE_DIR
323+
folder_path = logs_dir / selected_folder
269324

270-
if not selected_folder or not (logs_dir / selected_folder).exists():
271-
return render_template("no_logs.html", log_folders=log_folders)
325+
if not folder_path.exists() or not is_game_folder(folder_path):
326+
return redirect(url_for("game_picker"))
272327

273328
# Parse the selected game
274-
parser = LogParser(logs_dir / selected_folder)
329+
parser = LogParser(folder_path)
275330
metadata = parser.parse_game_metadata()
276331
available_trajectories = parser.get_available_trajectories()
277332

@@ -285,19 +340,26 @@ def index():
285340
trajectories_by_round[round_num].append(trajectory)
286341

287342
# Get the full path of the selected folder
288-
selected_folder_path = str(logs_dir / selected_folder) if selected_folder else ""
343+
selected_folder_path = str(folder_path)
289344

290345
return render_template(
291346
"index.html",
292-
log_folders=log_folders,
293-
log_folders_info=log_folders_info,
294347
selected_folder=selected_folder,
295348
selected_folder_path=selected_folder_path,
296349
metadata=metadata,
297350
trajectories_by_round=trajectories_by_round,
298351
)
299352

300353

354+
@app.route("/picker")
355+
def game_picker():
356+
"""Game picker page with recursive folder support"""
357+
logs_dir = LOG_BASE_DIR
358+
game_folders = find_all_game_folders(logs_dir)
359+
360+
return render_template("picker.html", game_folders=game_folders, base_dir=str(logs_dir))
361+
362+
301363
@app.route("/trajectory/<int:player_id>/<int:round_num>")
302364
def trajectory_detail(player_id: int, round_num: int):
303365
"""Get detailed trajectory data"""

0 commit comments

Comments
 (0)