FossilRepo

Add directory navigation, checkin detail, fix wiki/ticket/forum content Code browser: - Clickable folders navigate into subdirectories - Breadcrumb path navigation (project / dir / subdir) - Separate URL routes for directory browsing vs file viewing Checkin detail page: - New view at /fossil/checkin/<uuid>/ showing commit info - Lists all files changed (added/modified/deleted) with badges - Parent commit link, branch tag, merge indicator - Clickable commit hashes throughout timeline and code browser Content fixes: - Wiki: fixed W card content extraction (use byte size, not regex) - Tickets: added body, severity, resolution fields to detail page - Forum: read post bodies from blob artifacts, use forumpost table for proper threading (froot/firt), show content in list and thread - Timeline: dates shown in Y-m-d H:i format, hashes are clickable links

lmata 2026-04-06 13:36 trunk
Commit addc7d8f93a5090ae46adb25b1beda52f0613636ea392dc7d8e58ca43a2e066f
+163 -24
--- fossil/reader.py
+++ fossil/reader.py
@@ -35,10 +35,26 @@
3535
is_dir: bool = False
3636
last_commit_message: str = ""
3737
last_commit_user: str = ""
3838
last_commit_time: datetime | None = None
3939
40
+
41
+@dataclass
42
+class CheckinDetail:
43
+ uuid: str
44
+ timestamp: datetime
45
+ user: str
46
+ comment: str
47
+ branch: str = ""
48
+ parent_uuid: str = ""
49
+ is_merge: bool = False
50
+ files_changed: list = None # list of dicts: {name, change_type, uuid, prev_uuid}
51
+
52
+ def __post_init__(self):
53
+ if self.files_changed is None:
54
+ self.files_changed = []
55
+
4056
4157
@dataclass
4258
class TicketEntry:
4359
uuid: str
4460
title: str
@@ -46,10 +62,13 @@
4662
type: str
4763
created: datetime
4864
owner: str
4965
subsystem: str = ""
5066
priority: str = ""
67
+ severity: str = ""
68
+ resolution: str = ""
69
+ body: str = "" # main comment/description
5170
5271
5372
@dataclass
5473
class WikiPage:
5574
name: str
@@ -116,17 +135,20 @@
116135
117136
def _extract_wiki_content(artifact_text: str) -> str:
118137
"""Extract wiki body from a Fossil wiki artifact.
119138
120139
Format: header cards (D/L/P/U lines), then W <size>\\n<content>\\nZ <hash>
140
+ The W card specifies the byte count of the content that follows.
121141
"""
122142
import re
123143
124
- match = re.search(r"^W \d+\n(.*?)(?:\nZ [0-9a-f]+)?$", artifact_text, re.DOTALL | re.MULTILINE)
125
- if match:
126
- return match.group(1).strip()
127
- return ""
144
+ match = re.search(r"^W (\d+)\n", artifact_text, re.MULTILINE)
145
+ if not match:
146
+ return ""
147
+ start = match.end()
148
+ size = int(match.group(1))
149
+ return artifact_text[start : start + size]
128150
129151
130152
class FossilReader:
131153
"""Read-only interface to a .fossil SQLite database."""
132154
@@ -230,13 +252,11 @@
230252
pass
231253
232254
# Get parent info from plink for DAG
233255
if row["type"] == "ci":
234256
try:
235
- parents = self.conn.execute(
236
- "SELECT pid, isprim FROM plink WHERE cid=?", (row["rid"],)
237
- ).fetchall()
257
+ parents = self.conn.execute("SELECT pid, isprim FROM plink WHERE cid=?", (row["rid"],)).fetchall()
238258
for p in parents:
239259
if p["isprim"]:
240260
parent_rid = p["pid"]
241261
is_merge = len(parents) > 1
242262
except sqlite3.OperationalError:
@@ -270,10 +290,101 @@
270290
branch_rails[b] = next_rail
271291
next_rail += 1
272292
entry.rail = branch_rails[b]
273293
274294
return entries
295
+
296
+ # --- Checkin Detail ---
297
+
298
+ def get_checkin_detail(self, uuid: str) -> CheckinDetail | None:
299
+ """Get full details for a specific checkin, including changed files."""
300
+ try:
301
+ row = self.conn.execute(
302
+ "SELECT blob.rid, blob.uuid, event.mtime, event.user, event.comment "
303
+ "FROM event JOIN blob ON event.objid=blob.rid "
304
+ "WHERE blob.uuid LIKE ? AND event.type='ci'",
305
+ (uuid + "%",),
306
+ ).fetchone()
307
+ if not row:
308
+ return None
309
+
310
+ rid = row["rid"]
311
+ full_uuid = row["uuid"]
312
+
313
+ # Get branch
314
+ branch = ""
315
+ try:
316
+ br = self.conn.execute(
317
+ "SELECT tag.tagname FROM tagxref JOIN tag ON tagxref.tagid=tag.tagid WHERE tagxref.rid=? AND tag.tagname LIKE 'sym-%'",
318
+ (rid,),
319
+ ).fetchone()
320
+ if br:
321
+ branch = br[0].replace("sym-", "", 1)
322
+ except sqlite3.OperationalError:
323
+ pass
324
+
325
+ # Get parent
326
+ parent_uuid = ""
327
+ is_merge = False
328
+ try:
329
+ parents = self.conn.execute("SELECT pid, isprim FROM plink WHERE cid=?", (rid,)).fetchall()
330
+ for p in parents:
331
+ if p["isprim"]:
332
+ parent_row = self.conn.execute("SELECT uuid FROM blob WHERE rid=?", (p["pid"],)).fetchone()
333
+ if parent_row:
334
+ parent_uuid = parent_row["uuid"]
335
+ is_merge = len(parents) > 1
336
+ except sqlite3.OperationalError:
337
+ pass
338
+
339
+ # Get changed files from mlink
340
+ files_changed = []
341
+ try:
342
+ mlinks = self.conn.execute(
343
+ """
344
+ SELECT fn.name, ml.fid, ml.pid,
345
+ b_new.uuid as new_uuid,
346
+ b_old.uuid as old_uuid
347
+ FROM mlink ml
348
+ JOIN filename fn ON ml.fnid = fn.fnid
349
+ LEFT JOIN blob b_new ON ml.fid = b_new.rid
350
+ LEFT JOIN blob b_old ON ml.pid = b_old.rid
351
+ WHERE ml.mid = ?
352
+ ORDER BY fn.name
353
+ """,
354
+ (rid,),
355
+ ).fetchall()
356
+ for ml in mlinks:
357
+ if ml["fid"] == 0:
358
+ change_type = "deleted"
359
+ elif ml["pid"] == 0:
360
+ change_type = "added"
361
+ else:
362
+ change_type = "modified"
363
+ files_changed.append(
364
+ {
365
+ "name": ml["name"],
366
+ "change_type": change_type,
367
+ "uuid": ml["new_uuid"] or "",
368
+ "prev_uuid": ml["old_uuid"] or "",
369
+ }
370
+ )
371
+ except sqlite3.OperationalError:
372
+ pass
373
+
374
+ return CheckinDetail(
375
+ uuid=full_uuid,
376
+ timestamp=_julian_to_datetime(row["mtime"]),
377
+ user=row["user"] or "",
378
+ comment=row["comment"] or "",
379
+ branch=branch,
380
+ parent_uuid=parent_uuid,
381
+ is_merge=is_merge,
382
+ files_changed=files_changed,
383
+ )
384
+ except sqlite3.OperationalError:
385
+ return None
275386
276387
# --- Code / Files ---
277388
278389
def get_latest_checkin_uuid(self) -> str | None:
279390
try:
@@ -369,11 +480,11 @@
369480
return entries
370481
371482
def get_ticket_detail(self, uuid: str) -> TicketEntry | None:
372483
try:
373484
row = self.conn.execute(
374
- "SELECT tkt_uuid, title, status, type, tkt_ctime, subsystem, priority "
485
+ "SELECT tkt_uuid, title, status, type, tkt_ctime, subsystem, priority, severity, resolution, comment "
375486
"FROM ticket WHERE tkt_uuid LIKE ?",
376487
(uuid + "%",),
377488
).fetchone()
378489
if not row:
379490
return None
@@ -384,10 +495,13 @@
384495
type=row["type"] or "",
385496
created=_julian_to_datetime(row["tkt_ctime"]) if row["tkt_ctime"] else datetime.now(UTC),
386497
owner="",
387498
subsystem=row["subsystem"] or "",
388499
priority=row["priority"] or "",
500
+ severity=row["severity"] or "",
501
+ resolution=row["resolution"] or "",
502
+ body=row["comment"] or "",
389503
)
390504
except sqlite3.OperationalError:
391505
return None
392506
393507
# --- Wiki ---
@@ -456,58 +570,83 @@
456570
return None
457571
458572
# --- Forum ---
459573
460574
def get_forum_posts(self, limit: int = 50) -> list[ForumPost]:
575
+ """Get root forum posts (thread starters) with body content."""
461576
posts = []
462577
try:
463578
rows = self.conn.execute(
464579
"""
465
- SELECT blob.uuid, event.mtime, event.user, event.comment
466
- FROM event
467
- JOIN blob ON event.objid = blob.rid
468
- WHERE event.type = 'f'
469
- ORDER BY event.mtime DESC
580
+ SELECT b.uuid, fp.fmtime, e.user, e.comment, b.rid
581
+ FROM forumpost fp
582
+ JOIN blob b ON fp.fpid = b.rid
583
+ JOIN event e ON fp.fpid = e.objid
584
+ WHERE fp.firt = 0 AND fp.fprev = 0
585
+ ORDER BY fp.fmtime DESC
470586
LIMIT ?
471587
""",
472588
(limit,),
473589
).fetchall()
474590
for row in rows:
591
+ body = self._read_forum_body(row["rid"])
475592
posts.append(
476593
ForumPost(
477594
uuid=row["uuid"],
478595
title=row["comment"] or "",
479
- body="",
480
- timestamp=_julian_to_datetime(row["mtime"]),
596
+ body=body,
597
+ timestamp=_julian_to_datetime(row["fmtime"]),
481598
user=row["user"] or "",
482599
)
483600
)
484601
except sqlite3.OperationalError:
485602
pass
486603
return posts
487604
488605
def get_forum_thread(self, root_uuid: str) -> list[ForumPost]:
489
- # Forum threads in Fossil are linked via the forumpost table
606
+ """Get all posts in a forum thread by root post UUID."""
490607
posts = []
491608
try:
609
+ # Find root post rid
610
+ root_row = self.conn.execute("SELECT rid FROM blob WHERE uuid=?", (root_uuid,)).fetchone()
611
+ if not root_row:
612
+ return []
613
+ root_rid = root_row["rid"]
614
+
492615
rows = self.conn.execute(
493616
"""
494
- SELECT blob.uuid, event.mtime, event.user, event.comment
495
- FROM event
496
- JOIN blob ON event.objid = blob.rid
497
- WHERE event.type = 'f'
498
- ORDER BY event.mtime ASC
499
- """
617
+ SELECT b.uuid, fp.fmtime, e.user, e.comment, b.rid, fp.firt
618
+ FROM forumpost fp
619
+ JOIN blob b ON fp.fpid = b.rid
620
+ JOIN event e ON fp.fpid = e.objid
621
+ WHERE fp.froot = ?
622
+ ORDER BY fp.fmtime ASC
623
+ """,
624
+ (root_rid,),
500625
).fetchall()
501626
for row in rows:
627
+ body = self._read_forum_body(row["rid"])
502628
posts.append(
503629
ForumPost(
504630
uuid=row["uuid"],
505631
title=row["comment"] or "",
506
- body="",
507
- timestamp=_julian_to_datetime(row["mtime"]),
632
+ body=body,
633
+ timestamp=_julian_to_datetime(row["fmtime"]),
508634
user=row["user"] or "",
635
+ in_reply_to=str(row["firt"]) if row["firt"] else "",
509636
)
510637
)
511638
except sqlite3.OperationalError:
512639
pass
513640
return posts
641
+
642
+ def _read_forum_body(self, rid: int) -> str:
643
+ """Read and extract body text from a forum post artifact."""
644
+ try:
645
+ row = self.conn.execute("SELECT content FROM blob WHERE rid=?", (rid,)).fetchone()
646
+ if not row or not row[0]:
647
+ return ""
648
+ data = _decompress_blob(row[0])
649
+ text = data.decode("utf-8", errors="replace")
650
+ return _extract_wiki_content(text)
651
+ except Exception:
652
+ return ""
514653
--- fossil/reader.py
+++ fossil/reader.py
@@ -35,10 +35,26 @@
35 is_dir: bool = False
36 last_commit_message: str = ""
37 last_commit_user: str = ""
38 last_commit_time: datetime | None = None
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
41 @dataclass
42 class TicketEntry:
43 uuid: str
44 title: str
@@ -46,10 +62,13 @@
46 type: str
47 created: datetime
48 owner: str
49 subsystem: str = ""
50 priority: str = ""
 
 
 
51
52
53 @dataclass
54 class WikiPage:
55 name: str
@@ -116,17 +135,20 @@
116
117 def _extract_wiki_content(artifact_text: str) -> str:
118 """Extract wiki body from a Fossil wiki artifact.
119
120 Format: header cards (D/L/P/U lines), then W <size>\\n<content>\\nZ <hash>
 
121 """
122 import re
123
124 match = re.search(r"^W \d+\n(.*?)(?:\nZ [0-9a-f]+)?$", artifact_text, re.DOTALL | re.MULTILINE)
125 if match:
126 return match.group(1).strip()
127 return ""
 
 
128
129
130 class FossilReader:
131 """Read-only interface to a .fossil SQLite database."""
132
@@ -230,13 +252,11 @@
230 pass
231
232 # Get parent info from plink for DAG
233 if row["type"] == "ci":
234 try:
235 parents = self.conn.execute(
236 "SELECT pid, isprim FROM plink WHERE cid=?", (row["rid"],)
237 ).fetchall()
238 for p in parents:
239 if p["isprim"]:
240 parent_rid = p["pid"]
241 is_merge = len(parents) > 1
242 except sqlite3.OperationalError:
@@ -270,10 +290,101 @@
270 branch_rails[b] = next_rail
271 next_rail += 1
272 entry.rail = branch_rails[b]
273
274 return entries
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
276 # --- Code / Files ---
277
278 def get_latest_checkin_uuid(self) -> str | None:
279 try:
@@ -369,11 +480,11 @@
369 return entries
370
371 def get_ticket_detail(self, uuid: str) -> TicketEntry | None:
372 try:
373 row = self.conn.execute(
374 "SELECT tkt_uuid, title, status, type, tkt_ctime, subsystem, priority "
375 "FROM ticket WHERE tkt_uuid LIKE ?",
376 (uuid + "%",),
377 ).fetchone()
378 if not row:
379 return None
@@ -384,10 +495,13 @@
384 type=row["type"] or "",
385 created=_julian_to_datetime(row["tkt_ctime"]) if row["tkt_ctime"] else datetime.now(UTC),
386 owner="",
387 subsystem=row["subsystem"] or "",
388 priority=row["priority"] or "",
 
 
 
389 )
390 except sqlite3.OperationalError:
391 return None
392
393 # --- Wiki ---
@@ -456,58 +570,83 @@
456 return None
457
458 # --- Forum ---
459
460 def get_forum_posts(self, limit: int = 50) -> list[ForumPost]:
 
461 posts = []
462 try:
463 rows = self.conn.execute(
464 """
465 SELECT blob.uuid, event.mtime, event.user, event.comment
466 FROM event
467 JOIN blob ON event.objid = blob.rid
468 WHERE event.type = 'f'
469 ORDER BY event.mtime DESC
 
470 LIMIT ?
471 """,
472 (limit,),
473 ).fetchall()
474 for row in rows:
 
475 posts.append(
476 ForumPost(
477 uuid=row["uuid"],
478 title=row["comment"] or "",
479 body="",
480 timestamp=_julian_to_datetime(row["mtime"]),
481 user=row["user"] or "",
482 )
483 )
484 except sqlite3.OperationalError:
485 pass
486 return posts
487
488 def get_forum_thread(self, root_uuid: str) -> list[ForumPost]:
489 # Forum threads in Fossil are linked via the forumpost table
490 posts = []
491 try:
 
 
 
 
 
 
492 rows = self.conn.execute(
493 """
494 SELECT blob.uuid, event.mtime, event.user, event.comment
495 FROM event
496 JOIN blob ON event.objid = blob.rid
497 WHERE event.type = 'f'
498 ORDER BY event.mtime ASC
499 """
 
 
500 ).fetchall()
501 for row in rows:
 
502 posts.append(
503 ForumPost(
504 uuid=row["uuid"],
505 title=row["comment"] or "",
506 body="",
507 timestamp=_julian_to_datetime(row["mtime"]),
508 user=row["user"] or "",
 
509 )
510 )
511 except sqlite3.OperationalError:
512 pass
513 return posts
 
 
 
 
 
 
 
 
 
 
 
 
514
--- fossil/reader.py
+++ fossil/reader.py
@@ -35,10 +35,26 @@
35 is_dir: bool = False
36 last_commit_message: str = ""
37 last_commit_user: str = ""
38 last_commit_time: datetime | None = None
39
40
41 @dataclass
42 class CheckinDetail:
43 uuid: str
44 timestamp: datetime
45 user: str
46 comment: str
47 branch: str = ""
48 parent_uuid: str = ""
49 is_merge: bool = False
50 files_changed: list = None # list of dicts: {name, change_type, uuid, prev_uuid}
51
52 def __post_init__(self):
53 if self.files_changed is None:
54 self.files_changed = []
55
56
57 @dataclass
58 class TicketEntry:
59 uuid: str
60 title: str
@@ -46,10 +62,13 @@
62 type: str
63 created: datetime
64 owner: str
65 subsystem: str = ""
66 priority: str = ""
67 severity: str = ""
68 resolution: str = ""
69 body: str = "" # main comment/description
70
71
72 @dataclass
73 class WikiPage:
74 name: str
@@ -116,17 +135,20 @@
135
136 def _extract_wiki_content(artifact_text: str) -> str:
137 """Extract wiki body from a Fossil wiki artifact.
138
139 Format: header cards (D/L/P/U lines), then W <size>\\n<content>\\nZ <hash>
140 The W card specifies the byte count of the content that follows.
141 """
142 import re
143
144 match = re.search(r"^W (\d+)\n", artifact_text, re.MULTILINE)
145 if not match:
146 return ""
147 start = match.end()
148 size = int(match.group(1))
149 return artifact_text[start : start + size]
150
151
152 class FossilReader:
153 """Read-only interface to a .fossil SQLite database."""
154
@@ -230,13 +252,11 @@
252 pass
253
254 # Get parent info from plink for DAG
255 if row["type"] == "ci":
256 try:
257 parents = self.conn.execute("SELECT pid, isprim FROM plink WHERE cid=?", (row["rid"],)).fetchall()
 
 
258 for p in parents:
259 if p["isprim"]:
260 parent_rid = p["pid"]
261 is_merge = len(parents) > 1
262 except sqlite3.OperationalError:
@@ -270,10 +290,101 @@
290 branch_rails[b] = next_rail
291 next_rail += 1
292 entry.rail = branch_rails[b]
293
294 return entries
295
296 # --- Checkin Detail ---
297
298 def get_checkin_detail(self, uuid: str) -> CheckinDetail | None:
299 """Get full details for a specific checkin, including changed files."""
300 try:
301 row = self.conn.execute(
302 "SELECT blob.rid, blob.uuid, event.mtime, event.user, event.comment "
303 "FROM event JOIN blob ON event.objid=blob.rid "
304 "WHERE blob.uuid LIKE ? AND event.type='ci'",
305 (uuid + "%",),
306 ).fetchone()
307 if not row:
308 return None
309
310 rid = row["rid"]
311 full_uuid = row["uuid"]
312
313 # Get branch
314 branch = ""
315 try:
316 br = self.conn.execute(
317 "SELECT tag.tagname FROM tagxref JOIN tag ON tagxref.tagid=tag.tagid WHERE tagxref.rid=? AND tag.tagname LIKE 'sym-%'",
318 (rid,),
319 ).fetchone()
320 if br:
321 branch = br[0].replace("sym-", "", 1)
322 except sqlite3.OperationalError:
323 pass
324
325 # Get parent
326 parent_uuid = ""
327 is_merge = False
328 try:
329 parents = self.conn.execute("SELECT pid, isprim FROM plink WHERE cid=?", (rid,)).fetchall()
330 for p in parents:
331 if p["isprim"]:
332 parent_row = self.conn.execute("SELECT uuid FROM blob WHERE rid=?", (p["pid"],)).fetchone()
333 if parent_row:
334 parent_uuid = parent_row["uuid"]
335 is_merge = len(parents) > 1
336 except sqlite3.OperationalError:
337 pass
338
339 # Get changed files from mlink
340 files_changed = []
341 try:
342 mlinks = self.conn.execute(
343 """
344 SELECT fn.name, ml.fid, ml.pid,
345 b_new.uuid as new_uuid,
346 b_old.uuid as old_uuid
347 FROM mlink ml
348 JOIN filename fn ON ml.fnid = fn.fnid
349 LEFT JOIN blob b_new ON ml.fid = b_new.rid
350 LEFT JOIN blob b_old ON ml.pid = b_old.rid
351 WHERE ml.mid = ?
352 ORDER BY fn.name
353 """,
354 (rid,),
355 ).fetchall()
356 for ml in mlinks:
357 if ml["fid"] == 0:
358 change_type = "deleted"
359 elif ml["pid"] == 0:
360 change_type = "added"
361 else:
362 change_type = "modified"
363 files_changed.append(
364 {
365 "name": ml["name"],
366 "change_type": change_type,
367 "uuid": ml["new_uuid"] or "",
368 "prev_uuid": ml["old_uuid"] or "",
369 }
370 )
371 except sqlite3.OperationalError:
372 pass
373
374 return CheckinDetail(
375 uuid=full_uuid,
376 timestamp=_julian_to_datetime(row["mtime"]),
377 user=row["user"] or "",
378 comment=row["comment"] or "",
379 branch=branch,
380 parent_uuid=parent_uuid,
381 is_merge=is_merge,
382 files_changed=files_changed,
383 )
384 except sqlite3.OperationalError:
385 return None
386
387 # --- Code / Files ---
388
389 def get_latest_checkin_uuid(self) -> str | None:
390 try:
@@ -369,11 +480,11 @@
480 return entries
481
482 def get_ticket_detail(self, uuid: str) -> TicketEntry | None:
483 try:
484 row = self.conn.execute(
485 "SELECT tkt_uuid, title, status, type, tkt_ctime, subsystem, priority, severity, resolution, comment "
486 "FROM ticket WHERE tkt_uuid LIKE ?",
487 (uuid + "%",),
488 ).fetchone()
489 if not row:
490 return None
@@ -384,10 +495,13 @@
495 type=row["type"] or "",
496 created=_julian_to_datetime(row["tkt_ctime"]) if row["tkt_ctime"] else datetime.now(UTC),
497 owner="",
498 subsystem=row["subsystem"] or "",
499 priority=row["priority"] or "",
500 severity=row["severity"] or "",
501 resolution=row["resolution"] or "",
502 body=row["comment"] or "",
503 )
504 except sqlite3.OperationalError:
505 return None
506
507 # --- Wiki ---
@@ -456,58 +570,83 @@
570 return None
571
572 # --- Forum ---
573
574 def get_forum_posts(self, limit: int = 50) -> list[ForumPost]:
575 """Get root forum posts (thread starters) with body content."""
576 posts = []
577 try:
578 rows = self.conn.execute(
579 """
580 SELECT b.uuid, fp.fmtime, e.user, e.comment, b.rid
581 FROM forumpost fp
582 JOIN blob b ON fp.fpid = b.rid
583 JOIN event e ON fp.fpid = e.objid
584 WHERE fp.firt = 0 AND fp.fprev = 0
585 ORDER BY fp.fmtime DESC
586 LIMIT ?
587 """,
588 (limit,),
589 ).fetchall()
590 for row in rows:
591 body = self._read_forum_body(row["rid"])
592 posts.append(
593 ForumPost(
594 uuid=row["uuid"],
595 title=row["comment"] or "",
596 body=body,
597 timestamp=_julian_to_datetime(row["fmtime"]),
598 user=row["user"] or "",
599 )
600 )
601 except sqlite3.OperationalError:
602 pass
603 return posts
604
605 def get_forum_thread(self, root_uuid: str) -> list[ForumPost]:
606 """Get all posts in a forum thread by root post UUID."""
607 posts = []
608 try:
609 # Find root post rid
610 root_row = self.conn.execute("SELECT rid FROM blob WHERE uuid=?", (root_uuid,)).fetchone()
611 if not root_row:
612 return []
613 root_rid = root_row["rid"]
614
615 rows = self.conn.execute(
616 """
617 SELECT b.uuid, fp.fmtime, e.user, e.comment, b.rid, fp.firt
618 FROM forumpost fp
619 JOIN blob b ON fp.fpid = b.rid
620 JOIN event e ON fp.fpid = e.objid
621 WHERE fp.froot = ?
622 ORDER BY fp.fmtime ASC
623 """,
624 (root_rid,),
625 ).fetchall()
626 for row in rows:
627 body = self._read_forum_body(row["rid"])
628 posts.append(
629 ForumPost(
630 uuid=row["uuid"],
631 title=row["comment"] or "",
632 body=body,
633 timestamp=_julian_to_datetime(row["fmtime"]),
634 user=row["user"] or "",
635 in_reply_to=str(row["firt"]) if row["firt"] else "",
636 )
637 )
638 except sqlite3.OperationalError:
639 pass
640 return posts
641
642 def _read_forum_body(self, rid: int) -> str:
643 """Read and extract body text from a forum post artifact."""
644 try:
645 row = self.conn.execute("SELECT content FROM blob WHERE rid=?", (rid,)).fetchone()
646 if not row or not row[0]:
647 return ""
648 data = _decompress_blob(row[0])
649 text = data.decode("utf-8", errors="replace")
650 return _extract_wiki_content(text)
651 except Exception:
652 return ""
653
+3 -1
--- fossil/urls.py
+++ fossil/urls.py
@@ -4,12 +4,14 @@
44
55
app_name = "fossil"
66
77
urlpatterns = [
88
path("code/", views.code_browser, name="code"),
9
- path("code/<path:filepath>", views.code_file, name="code_file"),
9
+ path("code/tree/<path:dirpath>/", views.code_browser, name="code_dir"),
10
+ path("code/file/<path:filepath>", views.code_file, name="code_file"),
1011
path("timeline/", views.timeline, name="timeline"),
12
+ path("checkin/<str:checkin_uuid>/", views.checkin_detail, name="checkin_detail"),
1113
path("tickets/", views.ticket_list, name="tickets"),
1214
path("tickets/<str:ticket_uuid>/", views.ticket_detail, name="ticket_detail"),
1315
path("wiki/", views.wiki_list, name="wiki"),
1416
path("wiki/page/<path:page_name>", views.wiki_page, name="wiki_page"),
1517
path("forum/", views.forum_list, name="forum"),
1618
--- fossil/urls.py
+++ fossil/urls.py
@@ -4,12 +4,14 @@
4
5 app_name = "fossil"
6
7 urlpatterns = [
8 path("code/", views.code_browser, name="code"),
9 path("code/<path:filepath>", views.code_file, name="code_file"),
 
10 path("timeline/", views.timeline, name="timeline"),
 
11 path("tickets/", views.ticket_list, name="tickets"),
12 path("tickets/<str:ticket_uuid>/", views.ticket_detail, name="ticket_detail"),
13 path("wiki/", views.wiki_list, name="wiki"),
14 path("wiki/page/<path:page_name>", views.wiki_page, name="wiki_page"),
15 path("forum/", views.forum_list, name="forum"),
16
--- fossil/urls.py
+++ fossil/urls.py
@@ -4,12 +4,14 @@
4
5 app_name = "fossil"
6
7 urlpatterns = [
8 path("code/", views.code_browser, name="code"),
9 path("code/tree/<path:dirpath>/", views.code_browser, name="code_dir"),
10 path("code/file/<path:filepath>", views.code_file, name="code_file"),
11 path("timeline/", views.timeline, name="timeline"),
12 path("checkin/<str:checkin_uuid>/", views.checkin_detail, name="checkin_detail"),
13 path("tickets/", views.ticket_list, name="tickets"),
14 path("tickets/<str:ticket_uuid>/", views.ticket_detail, name="ticket_detail"),
15 path("wiki/", views.wiki_list, name="wiki"),
16 path("wiki/page/<path:page_name>", views.wiki_page, name="wiki_page"),
17 path("forum/", views.forum_list, name="forum"),
18
+73 -20
--- fossil/views.py
+++ fossil/views.py
@@ -23,33 +23,42 @@
2323
2424
# --- Code Browser ---
2525
2626
2727
@login_required
28
-def code_browser(request, slug):
28
+def code_browser(request, slug, dirpath=""):
2929
P.PROJECT_VIEW.check(request.user)
3030
project, fossil_repo, reader = _get_repo_and_reader(slug)
3131
3232
with reader:
3333
checkin_uuid = reader.get_latest_checkin_uuid()
3434
files = reader.get_files_at_checkin(checkin_uuid) if checkin_uuid else []
3535
metadata = reader.get_metadata()
3636
latest_commit = reader.get_timeline(limit=1, event_type="ci")
3737
38
- # Build directory tree from flat file list
39
- tree = _build_file_tree(files)
38
+ # Build directory listing for the current path
39
+ tree = _build_file_tree(files, current_dir=dirpath)
40
+
41
+ # Build breadcrumbs
42
+ breadcrumbs = []
43
+ if dirpath:
44
+ parts = dirpath.strip("/").split("/")
45
+ for i, part in enumerate(parts):
46
+ breadcrumbs.append({"name": part, "path": "/".join(parts[: i + 1])})
4047
4148
if request.headers.get("HX-Request"):
42
- return render(request, "fossil/partials/file_tree.html", {"tree": tree, "project": project})
49
+ return render(request, "fossil/partials/file_tree.html", {"tree": tree, "project": project, "current_dir": dirpath})
4350
4451
return render(
4552
request,
4653
"fossil/code_browser.html",
4754
{
4855
"project": project,
4956
"fossil_repo": fossil_repo,
5057
"tree": tree,
58
+ "current_dir": dirpath,
59
+ "breadcrumbs": breadcrumbs,
5160
"checkin_uuid": checkin_uuid,
5261
"metadata": metadata,
5362
"latest_commit": latest_commit[0] if latest_commit else None,
5463
"active_tab": "code",
5564
},
@@ -86,24 +95,57 @@
8695
is_binary = True
8796
8897
# Determine language for syntax highlighting
8998
ext = filepath.rsplit(".", 1)[-1] if "." in filepath else ""
9099
100
+ # Build breadcrumbs for file path
101
+ parts = filepath.split("/")
102
+ file_breadcrumbs = []
103
+ for i, part in enumerate(parts):
104
+ file_breadcrumbs.append({"name": part, "path": "/".join(parts[: i + 1])})
105
+
91106
return render(
92107
request,
93108
"fossil/code_file.html",
94109
{
95110
"project": project,
96111
"fossil_repo": fossil_repo,
97112
"filepath": filepath,
113
+ "file_breadcrumbs": file_breadcrumbs,
98114
"content": content,
99115
"is_binary": is_binary,
100116
"language": ext,
101117
"active_tab": "code",
102118
},
103119
)
104120
121
+
122
+# --- Checkin Detail ---
123
+
124
+
125
+@login_required
126
+def checkin_detail(request, slug, checkin_uuid):
127
+ P.PROJECT_VIEW.check(request.user)
128
+ project, fossil_repo, reader = _get_repo_and_reader(slug)
129
+
130
+ with reader:
131
+ checkin = reader.get_checkin_detail(checkin_uuid)
132
+
133
+ if not checkin:
134
+ raise Http404("Checkin not found")
135
+
136
+ return render(
137
+ request,
138
+ "fossil/checkin_detail.html",
139
+ {
140
+ "project": project,
141
+ "fossil_repo": fossil_repo,
142
+ "checkin": checkin,
143
+ "active_tab": "timeline",
144
+ },
145
+ )
146
+
105147
106148
# --- Timeline ---
107149
108150
109151
@login_required
@@ -302,52 +344,63 @@
302344
303345
304346
# --- Helpers ---
305347
306348
307
-def _build_file_tree(files):
308
- """Build a flat sorted list for the top-level directory view (like GitHub).
349
+def _build_file_tree(files, current_dir=""):
350
+ """Build a flat sorted list for the directory view at a given path.
309351
310
- Shows directories and files at the root level only. Directories are sorted first.
311
- Each directory gets the most recent commit info from its children.
352
+ Shows immediate children (dirs and files) of current_dir. Directories first.
353
+ Each directory gets the most recent commit info from its descendants.
312354
"""
313
- dirs = {} # dir_name -> most recent file entry
314
- root_files = []
355
+ prefix = (current_dir.strip("/") + "/") if current_dir else ""
356
+ prefix_len = len(prefix)
357
+
358
+ dirs = {} # immediate child dir name -> most recent file entry
359
+ dir_files = [] # immediate child files
315360
316361
for f in files:
317362
# Skip files with characters that break URL routing
318363
if "\n" in f.name or "\r" in f.name or "\x00" in f.name:
319364
continue
320
- parts = f.name.split("/")
365
+ # Only consider files under current_dir
366
+ if not f.name.startswith(prefix):
367
+ continue
368
+ # Get the relative path after prefix
369
+ relative = f.name[prefix_len:]
370
+ parts = relative.split("/")
371
+
321372
if len(parts) > 1:
322
- # File is inside a directory
323
- dir_name = parts[0]
324
- if dir_name not in dirs or (
325
- f.last_commit_time and (not dirs[dir_name].last_commit_time or f.last_commit_time > dirs[dir_name].last_commit_time)
373
+ # This file is inside a subdirectory
374
+ child_dir = parts[0]
375
+ if child_dir not in dirs or (
376
+ f.last_commit_time and (not dirs[child_dir].last_commit_time or f.last_commit_time > dirs[child_dir].last_commit_time)
326377
):
327
- dirs[dir_name] = f
378
+ dirs[child_dir] = f
328379
else:
329
- root_files.append(f)
380
+ dir_files.append(f)
330381
331382
entries = []
332383
# Directories first (sorted)
333384
for dir_name in sorted(dirs):
334385
f = dirs[dir_name]
386
+ full_dir_path = (prefix + dir_name) if prefix else dir_name
335387
entries.append(
336388
{
337389
"name": dir_name,
338
- "path": dir_name,
390
+ "path": full_dir_path,
339391
"is_dir": True,
340392
"commit_message": f.last_commit_message,
341393
"commit_time": f.last_commit_time,
342394
}
343395
)
344396
# Then files (sorted)
345
- for f in sorted(root_files, key=lambda x: x.name):
397
+ for f in sorted(dir_files, key=lambda x: x.name):
398
+ filename = f.name[prefix_len:] if prefix else f.name
346399
entries.append(
347400
{
348
- "name": f.name,
401
+ "name": filename,
349402
"path": f.name,
350403
"is_dir": False,
351404
"file": f,
352405
"commit_message": f.last_commit_message,
353406
"commit_time": f.last_commit_time,
354407
355408
ADDED templates/fossil/checkin_detail.html
--- fossil/views.py
+++ fossil/views.py
@@ -23,33 +23,42 @@
23
24 # --- Code Browser ---
25
26
27 @login_required
28 def code_browser(request, slug):
29 P.PROJECT_VIEW.check(request.user)
30 project, fossil_repo, reader = _get_repo_and_reader(slug)
31
32 with reader:
33 checkin_uuid = reader.get_latest_checkin_uuid()
34 files = reader.get_files_at_checkin(checkin_uuid) if checkin_uuid else []
35 metadata = reader.get_metadata()
36 latest_commit = reader.get_timeline(limit=1, event_type="ci")
37
38 # Build directory tree from flat file list
39 tree = _build_file_tree(files)
 
 
 
 
 
 
 
40
41 if request.headers.get("HX-Request"):
42 return render(request, "fossil/partials/file_tree.html", {"tree": tree, "project": project})
43
44 return render(
45 request,
46 "fossil/code_browser.html",
47 {
48 "project": project,
49 "fossil_repo": fossil_repo,
50 "tree": tree,
 
 
51 "checkin_uuid": checkin_uuid,
52 "metadata": metadata,
53 "latest_commit": latest_commit[0] if latest_commit else None,
54 "active_tab": "code",
55 },
@@ -86,24 +95,57 @@
86 is_binary = True
87
88 # Determine language for syntax highlighting
89 ext = filepath.rsplit(".", 1)[-1] if "." in filepath else ""
90
 
 
 
 
 
 
91 return render(
92 request,
93 "fossil/code_file.html",
94 {
95 "project": project,
96 "fossil_repo": fossil_repo,
97 "filepath": filepath,
 
98 "content": content,
99 "is_binary": is_binary,
100 "language": ext,
101 "active_tab": "code",
102 },
103 )
104
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
106 # --- Timeline ---
107
108
109 @login_required
@@ -302,52 +344,63 @@
302
303
304 # --- Helpers ---
305
306
307 def _build_file_tree(files):
308 """Build a flat sorted list for the top-level directory view (like GitHub).
309
310 Shows directories and files at the root level only. Directories are sorted first.
311 Each directory gets the most recent commit info from its children.
312 """
313 dirs = {} # dir_name -> most recent file entry
314 root_files = []
 
 
 
315
316 for f in files:
317 # Skip files with characters that break URL routing
318 if "\n" in f.name or "\r" in f.name or "\x00" in f.name:
319 continue
320 parts = f.name.split("/")
 
 
 
 
 
 
321 if len(parts) > 1:
322 # File is inside a directory
323 dir_name = parts[0]
324 if dir_name not in dirs or (
325 f.last_commit_time and (not dirs[dir_name].last_commit_time or f.last_commit_time > dirs[dir_name].last_commit_time)
326 ):
327 dirs[dir_name] = f
328 else:
329 root_files.append(f)
330
331 entries = []
332 # Directories first (sorted)
333 for dir_name in sorted(dirs):
334 f = dirs[dir_name]
 
335 entries.append(
336 {
337 "name": dir_name,
338 "path": dir_name,
339 "is_dir": True,
340 "commit_message": f.last_commit_message,
341 "commit_time": f.last_commit_time,
342 }
343 )
344 # Then files (sorted)
345 for f in sorted(root_files, key=lambda x: x.name):
 
346 entries.append(
347 {
348 "name": f.name,
349 "path": f.name,
350 "is_dir": False,
351 "file": f,
352 "commit_message": f.last_commit_message,
353 "commit_time": f.last_commit_time,
354
355 DDED templates/fossil/checkin_detail.html
--- fossil/views.py
+++ fossil/views.py
@@ -23,33 +23,42 @@
23
24 # --- Code Browser ---
25
26
27 @login_required
28 def code_browser(request, slug, dirpath=""):
29 P.PROJECT_VIEW.check(request.user)
30 project, fossil_repo, reader = _get_repo_and_reader(slug)
31
32 with reader:
33 checkin_uuid = reader.get_latest_checkin_uuid()
34 files = reader.get_files_at_checkin(checkin_uuid) if checkin_uuid else []
35 metadata = reader.get_metadata()
36 latest_commit = reader.get_timeline(limit=1, event_type="ci")
37
38 # Build directory listing for the current path
39 tree = _build_file_tree(files, current_dir=dirpath)
40
41 # Build breadcrumbs
42 breadcrumbs = []
43 if dirpath:
44 parts = dirpath.strip("/").split("/")
45 for i, part in enumerate(parts):
46 breadcrumbs.append({"name": part, "path": "/".join(parts[: i + 1])})
47
48 if request.headers.get("HX-Request"):
49 return render(request, "fossil/partials/file_tree.html", {"tree": tree, "project": project, "current_dir": dirpath})
50
51 return render(
52 request,
53 "fossil/code_browser.html",
54 {
55 "project": project,
56 "fossil_repo": fossil_repo,
57 "tree": tree,
58 "current_dir": dirpath,
59 "breadcrumbs": breadcrumbs,
60 "checkin_uuid": checkin_uuid,
61 "metadata": metadata,
62 "latest_commit": latest_commit[0] if latest_commit else None,
63 "active_tab": "code",
64 },
@@ -86,24 +95,57 @@
95 is_binary = True
96
97 # Determine language for syntax highlighting
98 ext = filepath.rsplit(".", 1)[-1] if "." in filepath else ""
99
100 # Build breadcrumbs for file path
101 parts = filepath.split("/")
102 file_breadcrumbs = []
103 for i, part in enumerate(parts):
104 file_breadcrumbs.append({"name": part, "path": "/".join(parts[: i + 1])})
105
106 return render(
107 request,
108 "fossil/code_file.html",
109 {
110 "project": project,
111 "fossil_repo": fossil_repo,
112 "filepath": filepath,
113 "file_breadcrumbs": file_breadcrumbs,
114 "content": content,
115 "is_binary": is_binary,
116 "language": ext,
117 "active_tab": "code",
118 },
119 )
120
121
122 # --- Checkin Detail ---
123
124
125 @login_required
126 def checkin_detail(request, slug, checkin_uuid):
127 P.PROJECT_VIEW.check(request.user)
128 project, fossil_repo, reader = _get_repo_and_reader(slug)
129
130 with reader:
131 checkin = reader.get_checkin_detail(checkin_uuid)
132
133 if not checkin:
134 raise Http404("Checkin not found")
135
136 return render(
137 request,
138 "fossil/checkin_detail.html",
139 {
140 "project": project,
141 "fossil_repo": fossil_repo,
142 "checkin": checkin,
143 "active_tab": "timeline",
144 },
145 )
146
147
148 # --- Timeline ---
149
150
151 @login_required
@@ -302,52 +344,63 @@
344
345
346 # --- Helpers ---
347
348
349 def _build_file_tree(files, current_dir=""):
350 """Build a flat sorted list for the directory view at a given path.
351
352 Shows immediate children (dirs and files) of current_dir. Directories first.
353 Each directory gets the most recent commit info from its descendants.
354 """
355 prefix = (current_dir.strip("/") + "/") if current_dir else ""
356 prefix_len = len(prefix)
357
358 dirs = {} # immediate child dir name -> most recent file entry
359 dir_files = [] # immediate child files
360
361 for f in files:
362 # Skip files with characters that break URL routing
363 if "\n" in f.name or "\r" in f.name or "\x00" in f.name:
364 continue
365 # Only consider files under current_dir
366 if not f.name.startswith(prefix):
367 continue
368 # Get the relative path after prefix
369 relative = f.name[prefix_len:]
370 parts = relative.split("/")
371
372 if len(parts) > 1:
373 # This file is inside a subdirectory
374 child_dir = parts[0]
375 if child_dir not in dirs or (
376 f.last_commit_time and (not dirs[child_dir].last_commit_time or f.last_commit_time > dirs[child_dir].last_commit_time)
377 ):
378 dirs[child_dir] = f
379 else:
380 dir_files.append(f)
381
382 entries = []
383 # Directories first (sorted)
384 for dir_name in sorted(dirs):
385 f = dirs[dir_name]
386 full_dir_path = (prefix + dir_name) if prefix else dir_name
387 entries.append(
388 {
389 "name": dir_name,
390 "path": full_dir_path,
391 "is_dir": True,
392 "commit_message": f.last_commit_message,
393 "commit_time": f.last_commit_time,
394 }
395 )
396 # Then files (sorted)
397 for f in sorted(dir_files, key=lambda x: x.name):
398 filename = f.name[prefix_len:] if prefix else f.name
399 entries.append(
400 {
401 "name": filename,
402 "path": f.name,
403 "is_dir": False,
404 "file": f,
405 "commit_message": f.last_commit_message,
406 "commit_time": f.last_commit_time,
407
408 DDED templates/fossil/checkin_detail.html
--- a/templates/fossil/checkin_detail.html
+++ b/templates/fossil/checkin_detail.html
@@ -0,0 +1,73 @@
1
+{% extends "base.html" %}
2
+{% block title %}{{ checkin.uuid|truncatechars:12 }} — {{ project.name }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6
+{% include "fossil/_project_nav.html" %}
7
+
8
+<div class="space-y-4">
9
+ <!-- Commit header -->
10
+ <div class="rounded-lg bg-gray-800 border border-gray-700">
11
+ <div class="px-6 py-5">
12
+ <p class="text-lg text-gray-100 leading-relaxed">{{ checkin.comment }}</p>
13
+ <div class="mt-3 flex items-center gap-4 flex-wrap text-sm">
14
+ <span class="font-medium text-gray-200">{{ checkin.user }}</span>
15
+ <span class="text-gray-500">{{ checkin.timestamp|date:"N j, Y g:i a" }}</span>
16
+ {% if checkin.branch %}
17
+ <span class="inline-flex items-center rounded-md bg-brand/10 border border-brand/20 px-2 py-0.5 text-xs text-brand-light">
18
+ {{ checkin.branch }}
19
+ </span>
20
+ {% endif %}
21
+ {% if checkin.is_merge %}
22
+ <span class="inline-flex items-center rounded-full bg-purple-900/50 px-2 py-0.5 text-xs text-purple-300">merge</span>
23
+ {% endif %}
24
+ </div>
25
+ </div>
26
+ <div class="px-6 py-3 border-t border-gray-700 bg-gray-800/50 flex items-center gap-6 text-xs">
27
+ <div class="flex items-center gap-2">
28
+ <span class="text-gray-500">Commit</span>
29
+ <code class="font-mono text-gray-300">{{ checkin.uuid|truncatechars:16 }}</code>
30
+ </div>
31
+ {% if checkin.parent_uuid %}
32
+ <div class="flex items-center gap-2">
33
+ <span class="text-gray-500">Parent</span>
34
+ <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=checkin.parent_uuid %}"
35
+ class="font-mono text-brand-light hover:text-brand">{{ checkin.parent_uuid|truncatechars:12 }}</a>
36
+ </div>
37
+ {% endif %}
38
+ <div class="flex items-center gap-2">
39
+ <span class="text-gray-500">Files changed</span>
40
+ <span class="text-gray-300">{{ checkin.files_changed|length }}</span>
41
+ </div>
42
+ </div>
43
+ </div>
44
+
45
+ <!-- Changed files -->
46
+ {% if checkin.files_changed %}
47
+ <div class="rounded-lg bg-gray-800 border border-gray-700">
48
+ <div class="px-4 py-3 border-b border-gray-700 text-sm font-medium text-gray-300">
49
+ {{ checkin.files_changed|length }} file{{ checkin.files_changed|length|pluralize }} changed
50
+ </div>
51
+ <div class="divide-y divide-gray-700">
52
+ {% for f in checkin.files_changed %}
53
+ <div class="px-4 py-2.5 flex items-center gap-3 hover:bg-gray-700/30">
54
+ {% if f.change_type == "added" %}
55
+ <span class="inline-flex items-center justify-center w-5 h-5 rounded text-xs font-bold bg-green-900/50 text-green-300">A</span>
56
+ {% elif f.change_type == "deleted" %}
57
+ <span class="inline-flex items-center justify-center w-5 h-5 rounded text-xs font-bold bg-red-900/50 text-red-300">D</span>
58
+ {% else %}
59
+ <span class="inline-flex items-center justify-center w-5 h-5 rounded text-xs font-bold bg-yellow-900/50 text-yellow-300">M</span>
60
+ {% endif %}
61
+ {% if f.uuid %}
62
+ <a href="{% url 'fossil:code_file' slug=project.slug filepath=f.name %}"
63
+ class="text-sm font-mono text-brand-light hover:text-brand">{{ f.name }}</a>
64
+ {% else %}
65
+ <span class="text-sm font-mono text-gray-400">{{ f.name }}</span>
66
+ {% endif %}
67
+ </div>
68
+ {% endfor %}
69
+ </div>
70
+ </div>
71
+ {% endif %}
72
+</div>
73
+{% endblock %}
--- a/templates/fossil/checkin_detail.html
+++ b/templates/fossil/checkin_detail.html
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/checkin_detail.html
+++ b/templates/fossil/checkin_detail.html
@@ -0,0 +1,73 @@
1 {% extends "base.html" %}
2 {% block title %}{{ checkin.uuid|truncatechars:12 }} — {{ project.name }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6 {% include "fossil/_project_nav.html" %}
7
8 <div class="space-y-4">
9 <!-- Commit header -->
10 <div class="rounded-lg bg-gray-800 border border-gray-700">
11 <div class="px-6 py-5">
12 <p class="text-lg text-gray-100 leading-relaxed">{{ checkin.comment }}</p>
13 <div class="mt-3 flex items-center gap-4 flex-wrap text-sm">
14 <span class="font-medium text-gray-200">{{ checkin.user }}</span>
15 <span class="text-gray-500">{{ checkin.timestamp|date:"N j, Y g:i a" }}</span>
16 {% if checkin.branch %}
17 <span class="inline-flex items-center rounded-md bg-brand/10 border border-brand/20 px-2 py-0.5 text-xs text-brand-light">
18 {{ checkin.branch }}
19 </span>
20 {% endif %}
21 {% if checkin.is_merge %}
22 <span class="inline-flex items-center rounded-full bg-purple-900/50 px-2 py-0.5 text-xs text-purple-300">merge</span>
23 {% endif %}
24 </div>
25 </div>
26 <div class="px-6 py-3 border-t border-gray-700 bg-gray-800/50 flex items-center gap-6 text-xs">
27 <div class="flex items-center gap-2">
28 <span class="text-gray-500">Commit</span>
29 <code class="font-mono text-gray-300">{{ checkin.uuid|truncatechars:16 }}</code>
30 </div>
31 {% if checkin.parent_uuid %}
32 <div class="flex items-center gap-2">
33 <span class="text-gray-500">Parent</span>
34 <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=checkin.parent_uuid %}"
35 class="font-mono text-brand-light hover:text-brand">{{ checkin.parent_uuid|truncatechars:12 }}</a>
36 </div>
37 {% endif %}
38 <div class="flex items-center gap-2">
39 <span class="text-gray-500">Files changed</span>
40 <span class="text-gray-300">{{ checkin.files_changed|length }}</span>
41 </div>
42 </div>
43 </div>
44
45 <!-- Changed files -->
46 {% if checkin.files_changed %}
47 <div class="rounded-lg bg-gray-800 border border-gray-700">
48 <div class="px-4 py-3 border-b border-gray-700 text-sm font-medium text-gray-300">
49 {{ checkin.files_changed|length }} file{{ checkin.files_changed|length|pluralize }} changed
50 </div>
51 <div class="divide-y divide-gray-700">
52 {% for f in checkin.files_changed %}
53 <div class="px-4 py-2.5 flex items-center gap-3 hover:bg-gray-700/30">
54 {% if f.change_type == "added" %}
55 <span class="inline-flex items-center justify-center w-5 h-5 rounded text-xs font-bold bg-green-900/50 text-green-300">A</span>
56 {% elif f.change_type == "deleted" %}
57 <span class="inline-flex items-center justify-center w-5 h-5 rounded text-xs font-bold bg-red-900/50 text-red-300">D</span>
58 {% else %}
59 <span class="inline-flex items-center justify-center w-5 h-5 rounded text-xs font-bold bg-yellow-900/50 text-yellow-300">M</span>
60 {% endif %}
61 {% if f.uuid %}
62 <a href="{% url 'fossil:code_file' slug=project.slug filepath=f.name %}"
63 class="text-sm font-mono text-brand-light hover:text-brand">{{ f.name }}</a>
64 {% else %}
65 <span class="text-sm font-mono text-gray-400">{{ f.name }}</span>
66 {% endif %}
67 </div>
68 {% endfor %}
69 </div>
70 </div>
71 {% endif %}
72 </div>
73 {% endblock %}
--- templates/fossil/code_browser.html
+++ templates/fossil/code_browser.html
@@ -1,33 +1,49 @@
11
{% extends "base.html" %}
22
{% load humanize %}
3
-{% block title %}Code — {{ project.name }} — Fossilrepo{% endblock %}
3
+{% block title %}{% if current_dir %}{{ current_dir }} — {% endif %}Code — {{ project.name }} — Fossilrepo{% endblock %}
44
55
{% block content %}
66
<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
77
{% include "fossil/_project_nav.html" %}
88
99
<div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
10
- <!-- Latest commit bar -->
11
- {% if latest_commit %}
12
- <div class="px-4 py-3 border-b border-gray-700 flex items-center justify-between">
13
- <div class="flex items-center gap-3 min-w-0">
14
- <span class="text-sm font-medium text-gray-200 flex-shrink-0">{{ latest_commit.user }}</span>
15
- <span class="text-sm text-gray-400 truncate">{{ latest_commit.comment }}</span>
16
- </div>
17
- <div class="flex items-center gap-4 flex-shrink-0 text-sm text-gray-500">
18
- <code class="font-mono text-xs">{{ latest_commit.uuid|truncatechars:10 }}</code>
19
- <span>{{ latest_commit.timestamp|timesince }} ago</span>
20
- <span class="flex items-center gap-1">
21
- <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
10
+ <!-- Breadcrumb + commit bar -->
11
+ <div class="px-4 py-3 border-b border-gray-700">
12
+ <!-- Breadcrumbs -->
13
+ <div class="flex items-center justify-between">
14
+ <div class="flex items-center gap-1 text-sm min-w-0">
15
+ <a href="{% url 'fossil:code' slug=project.slug %}" class="text-brand-light hover:text-brand font-medium">{{ project.name }}</a>
16
+ {% for crumb in breadcrumbs %}
17
+ <span class="text-gray-600">/</span>
18
+ {% if forloop.last %}
19
+ <span class="text-gray-200 font-medium">{{ crumb.name }}</span>
20
+ {% else %}
21
+ <a href="{% url 'fossil:code_dir' slug=project.slug dirpath=crumb.path %}" class="text-brand-light hover:text-brand">{{ crumb.name }}</a>
22
+ {% endif %}
23
+ {% endfor %}
24
+ </div>
25
+ {% if metadata %}
26
+ <div class="flex items-center gap-2 flex-shrink-0 text-xs text-gray-500">
27
+ <svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
2228
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
2329
</svg>
2430
{{ metadata.checkin_count }} commit{{ metadata.checkin_count|pluralize }}
25
- </span>
31
+ </div>
32
+ {% endif %}
33
+ </div>
34
+
35
+ <!-- Latest commit info -->
36
+ {% if latest_commit %}
37
+ <div class="flex items-center gap-3 mt-2 text-xs text-gray-500">
38
+ <span class="font-medium text-gray-300">{{ latest_commit.user }}</span>
39
+ <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=latest_commit.uuid %}" class="text-gray-400 truncate hover:text-brand-light">{{ latest_commit.comment|truncatechars:80 }}</a>
40
+ <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=latest_commit.uuid %}" class="font-mono text-brand-light hover:text-brand">{{ latest_commit.uuid|truncatechars:10 }}</a>
41
+ <span>{{ latest_commit.timestamp|date:"Y-m-d H:i" }}</span>
2642
</div>
43
+ {% endif %}
2744
</div>
28
- {% endif %}
2945
3046
<!-- File table -->
3147
{% include "fossil/partials/file_tree.html" %}
3248
</div>
3349
{% endblock %}
3450
--- templates/fossil/code_browser.html
+++ templates/fossil/code_browser.html
@@ -1,33 +1,49 @@
1 {% extends "base.html" %}
2 {% load humanize %}
3 {% block title %}Code — {{ project.name }} — Fossilrepo{% endblock %}
4
5 {% block content %}
6 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
7 {% include "fossil/_project_nav.html" %}
8
9 <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
10 <!-- Latest commit bar -->
11 {% if latest_commit %}
12 <div class="px-4 py-3 border-b border-gray-700 flex items-center justify-between">
13 <div class="flex items-center gap-3 min-w-0">
14 <span class="text-sm font-medium text-gray-200 flex-shrink-0">{{ latest_commit.user }}</span>
15 <span class="text-sm text-gray-400 truncate">{{ latest_commit.comment }}</span>
16 </div>
17 <div class="flex items-center gap-4 flex-shrink-0 text-sm text-gray-500">
18 <code class="font-mono text-xs">{{ latest_commit.uuid|truncatechars:10 }}</code>
19 <span>{{ latest_commit.timestamp|timesince }} ago</span>
20 <span class="flex items-center gap-1">
21 <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
 
 
 
 
 
 
22 <path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
23 </svg>
24 {{ metadata.checkin_count }} commit{{ metadata.checkin_count|pluralize }}
25 </span>
 
 
 
 
 
 
 
 
 
 
26 </div>
 
27 </div>
28 {% endif %}
29
30 <!-- File table -->
31 {% include "fossil/partials/file_tree.html" %}
32 </div>
33 {% endblock %}
34
--- templates/fossil/code_browser.html
+++ templates/fossil/code_browser.html
@@ -1,33 +1,49 @@
1 {% extends "base.html" %}
2 {% load humanize %}
3 {% block title %}{% if current_dir %}{{ current_dir }} — {% endif %}Code — {{ project.name }} — Fossilrepo{% endblock %}
4
5 {% block content %}
6 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
7 {% include "fossil/_project_nav.html" %}
8
9 <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
10 <!-- Breadcrumb + commit bar -->
11 <div class="px-4 py-3 border-b border-gray-700">
12 <!-- Breadcrumbs -->
13 <div class="flex items-center justify-between">
14 <div class="flex items-center gap-1 text-sm min-w-0">
15 <a href="{% url 'fossil:code' slug=project.slug %}" class="text-brand-light hover:text-brand font-medium">{{ project.name }}</a>
16 {% for crumb in breadcrumbs %}
17 <span class="text-gray-600">/</span>
18 {% if forloop.last %}
19 <span class="text-gray-200 font-medium">{{ crumb.name }}</span>
20 {% else %}
21 <a href="{% url 'fossil:code_dir' slug=project.slug dirpath=crumb.path %}" class="text-brand-light hover:text-brand">{{ crumb.name }}</a>
22 {% endif %}
23 {% endfor %}
24 </div>
25 {% if metadata %}
26 <div class="flex items-center gap-2 flex-shrink-0 text-xs text-gray-500">
27 <svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
28 <path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
29 </svg>
30 {{ metadata.checkin_count }} commit{{ metadata.checkin_count|pluralize }}
31 </div>
32 {% endif %}
33 </div>
34
35 <!-- Latest commit info -->
36 {% if latest_commit %}
37 <div class="flex items-center gap-3 mt-2 text-xs text-gray-500">
38 <span class="font-medium text-gray-300">{{ latest_commit.user }}</span>
39 <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=latest_commit.uuid %}" class="text-gray-400 truncate hover:text-brand-light">{{ latest_commit.comment|truncatechars:80 }}</a>
40 <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=latest_commit.uuid %}" class="font-mono text-brand-light hover:text-brand">{{ latest_commit.uuid|truncatechars:10 }}</a>
41 <span>{{ latest_commit.timestamp|date:"Y-m-d H:i" }}</span>
42 </div>
43 {% endif %}
44 </div>
 
45
46 <!-- File table -->
47 {% include "fossil/partials/file_tree.html" %}
48 </div>
49 {% endblock %}
50
--- templates/fossil/code_file.html
+++ templates/fossil/code_file.html
@@ -8,17 +8,24 @@
88
99
{% block content %}
1010
<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
1111
{% include "fossil/_project_nav.html" %}
1212
13
-<div class="mb-4">
14
- <a href="{% url 'fossil:code' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to files</a>
15
-</div>
16
-
1713
<div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
14
+ <!-- File path breadcrumb -->
1815
<div class="px-4 py-3 border-b border-gray-700">
19
- <span class="font-mono text-sm text-gray-300">{{ filepath }}</span>
16
+ <div class="flex items-center gap-1 text-sm font-mono">
17
+ <a href="{% url 'fossil:code' slug=project.slug %}" class="text-brand-light hover:text-brand">{{ project.slug }}</a>
18
+ {% for crumb in file_breadcrumbs %}
19
+ <span class="text-gray-600">/</span>
20
+ {% if forloop.last %}
21
+ <span class="text-gray-200">{{ crumb.name }}</span>
22
+ {% else %}
23
+ <a href="{% url 'fossil:code_dir' slug=project.slug dirpath=crumb.path %}" class="text-brand-light hover:text-brand">{{ crumb.name }}</a>
24
+ {% endif %}
25
+ {% endfor %}
26
+ </div>
2027
</div>
2128
<div class="overflow-x-auto">
2229
{% if is_binary %}
2330
<p class="p-4 text-sm text-gray-500">{{ content }}</p>
2431
{% else %}
2532
--- templates/fossil/code_file.html
+++ templates/fossil/code_file.html
@@ -8,17 +8,24 @@
8
9 {% block content %}
10 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
11 {% include "fossil/_project_nav.html" %}
12
13 <div class="mb-4">
14 <a href="{% url 'fossil:code' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to files</a>
15 </div>
16
17 <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
 
18 <div class="px-4 py-3 border-b border-gray-700">
19 <span class="font-mono text-sm text-gray-300">{{ filepath }}</span>
 
 
 
 
 
 
 
 
 
 
20 </div>
21 <div class="overflow-x-auto">
22 {% if is_binary %}
23 <p class="p-4 text-sm text-gray-500">{{ content }}</p>
24 {% else %}
25
--- templates/fossil/code_file.html
+++ templates/fossil/code_file.html
@@ -8,17 +8,24 @@
8
9 {% block content %}
10 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
11 {% include "fossil/_project_nav.html" %}
12
 
 
 
 
13 <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
14 <!-- File path breadcrumb -->
15 <div class="px-4 py-3 border-b border-gray-700">
16 <div class="flex items-center gap-1 text-sm font-mono">
17 <a href="{% url 'fossil:code' slug=project.slug %}" class="text-brand-light hover:text-brand">{{ project.slug }}</a>
18 {% for crumb in file_breadcrumbs %}
19 <span class="text-gray-600">/</span>
20 {% if forloop.last %}
21 <span class="text-gray-200">{{ crumb.name }}</span>
22 {% else %}
23 <a href="{% url 'fossil:code_dir' slug=project.slug dirpath=crumb.path %}" class="text-brand-light hover:text-brand">{{ crumb.name }}</a>
24 {% endif %}
25 {% endfor %}
26 </div>
27 </div>
28 <div class="overflow-x-auto">
29 {% if is_binary %}
30 <p class="p-4 text-sm text-gray-500">{{ content }}</p>
31 {% else %}
32
--- templates/fossil/forum_list.html
+++ templates/fossil/forum_list.html
@@ -3,19 +3,25 @@
33
44
{% block content %}
55
<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
66
{% include "fossil/_project_nav.html" %}
77
8
-<div class="space-y-2">
8
+<div class="space-y-3">
99
{% for post in posts %}
10
- <div class="rounded-lg bg-gray-800 border border-gray-700 px-4 py-3">
11
- <a href="{% url 'fossil:forum_thread' slug=project.slug thread_uuid=post.uuid %}"
12
- class="text-brand-light hover:text-brand font-medium text-sm">
13
- {{ post.title|default:"(untitled)" }}
14
- </a>
15
- <div class="mt-1 text-xs text-gray-500">
16
- {{ post.user }} &middot; {{ post.timestamp|date:"N j, Y g:i a" }}
10
+ <div class="rounded-lg bg-gray-800 border border-gray-700 hover:border-gray-600 transition-colors">
11
+ <div class="px-5 py-4">
12
+ <a href="{% url 'fossil:forum_thread' slug=project.slug thread_uuid=post.uuid %}"
13
+ class="text-base font-medium text-brand-light hover:text-brand">
14
+ {{ post.title|default:"(untitled)" }}
15
+ </a>
16
+ {% if post.body %}
17
+ <p class="mt-1 text-sm text-gray-400 line-clamp-2">{{ post.body|truncatechars:200 }}</p>
18
+ {% endif %}
19
+ <div class="mt-2 flex items-center gap-3 text-xs text-gray-500">
20
+ <span class="font-medium text-gray-400">{{ post.user }}</span>
21
+ <span>{{ post.timestamp|timesince }} ago</span>
22
+ </div>
1723
</div>
1824
</div>
1925
{% empty %}
2026
<p class="text-sm text-gray-500 py-8 text-center">No forum posts.</p>
2127
{% endfor %}
2228
--- templates/fossil/forum_list.html
+++ templates/fossil/forum_list.html
@@ -3,19 +3,25 @@
3
4 {% block content %}
5 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6 {% include "fossil/_project_nav.html" %}
7
8 <div class="space-y-2">
9 {% for post in posts %}
10 <div class="rounded-lg bg-gray-800 border border-gray-700 px-4 py-3">
11 <a href="{% url 'fossil:forum_thread' slug=project.slug thread_uuid=post.uuid %}"
12 class="text-brand-light hover:text-brand font-medium text-sm">
13 {{ post.title|default:"(untitled)" }}
14 </a>
15 <div class="mt-1 text-xs text-gray-500">
16 {{ post.user }} &middot; {{ post.timestamp|date:"N j, Y g:i a" }}
 
 
 
 
 
 
17 </div>
18 </div>
19 {% empty %}
20 <p class="text-sm text-gray-500 py-8 text-center">No forum posts.</p>
21 {% endfor %}
22
--- templates/fossil/forum_list.html
+++ templates/fossil/forum_list.html
@@ -3,19 +3,25 @@
3
4 {% block content %}
5 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6 {% include "fossil/_project_nav.html" %}
7
8 <div class="space-y-3">
9 {% for post in posts %}
10 <div class="rounded-lg bg-gray-800 border border-gray-700 hover:border-gray-600 transition-colors">
11 <div class="px-5 py-4">
12 <a href="{% url 'fossil:forum_thread' slug=project.slug thread_uuid=post.uuid %}"
13 class="text-base font-medium text-brand-light hover:text-brand">
14 {{ post.title|default:"(untitled)" }}
15 </a>
16 {% if post.body %}
17 <p class="mt-1 text-sm text-gray-400 line-clamp-2">{{ post.body|truncatechars:200 }}</p>
18 {% endif %}
19 <div class="mt-2 flex items-center gap-3 text-xs text-gray-500">
20 <span class="font-medium text-gray-400">{{ post.user }}</span>
21 <span>{{ post.timestamp|timesince }} ago</span>
22 </div>
23 </div>
24 </div>
25 {% empty %}
26 <p class="text-sm text-gray-500 py-8 text-center">No forum posts.</p>
27 {% endfor %}
28
--- templates/fossil/forum_thread.html
+++ templates/fossil/forum_thread.html
@@ -9,17 +9,26 @@
99
<a href="{% url 'fossil:forum' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to forum</a>
1010
</div>
1111
1212
<div class="space-y-3">
1313
{% for post in posts %}
14
- <div class="rounded-lg bg-gray-800 border border-gray-700 px-5 py-4">
15
- <div class="flex items-center justify-between mb-2">
16
- <span class="text-sm font-medium text-gray-200">{{ post.user }}</span>
17
- <span class="text-xs text-gray-500">{{ post.timestamp|date:"N j, Y g:i a" }}</span>
18
- </div>
19
- <div class="text-sm text-gray-300">
20
- {{ post.title|default:post.body|default:"(empty)" }}
14
+ <div class="rounded-lg bg-gray-800 border border-gray-700 {% if post.in_reply_to %}ml-8{% endif %}">
15
+ <div class="px-5 py-4">
16
+ <div class="flex items-center justify-between mb-2">
17
+ <span class="text-sm font-medium text-gray-200">{{ post.user }}</span>
18
+ <span class="text-xs text-gray-500">{{ post.timestamp|timesince }} ago</span>
19
+ </div>
20
+ {% if post.title and forloop.first %}
21
+ <h2 class="text-lg font-semibold text-gray-100 mb-3">{{ post.title }}</h2>
22
+ {% endif %}
23
+ {% if post.body %}
24
+ <div class="prose prose-invert prose-sm prose-gray max-w-none">
25
+ {{ post.body|linebreaksbr }}
26
+ </div>
27
+ {% else %}
28
+ <p class="text-sm text-gray-500 italic">{{ post.title|default:"(empty)" }}</p>
29
+ {% endif %}
2130
</div>
2231
</div>
2332
{% empty %}
2433
<p class="text-sm text-gray-500 py-8 text-center">No posts in this thread.</p>
2534
{% endfor %}
2635
--- templates/fossil/forum_thread.html
+++ templates/fossil/forum_thread.html
@@ -9,17 +9,26 @@
9 <a href="{% url 'fossil:forum' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to forum</a>
10 </div>
11
12 <div class="space-y-3">
13 {% for post in posts %}
14 <div class="rounded-lg bg-gray-800 border border-gray-700 px-5 py-4">
15 <div class="flex items-center justify-between mb-2">
16 <span class="text-sm font-medium text-gray-200">{{ post.user }}</span>
17 <span class="text-xs text-gray-500">{{ post.timestamp|date:"N j, Y g:i a" }}</span>
18 </div>
19 <div class="text-sm text-gray-300">
20 {{ post.title|default:post.body|default:"(empty)" }}
 
 
 
 
 
 
 
 
 
21 </div>
22 </div>
23 {% empty %}
24 <p class="text-sm text-gray-500 py-8 text-center">No posts in this thread.</p>
25 {% endfor %}
26
--- templates/fossil/forum_thread.html
+++ templates/fossil/forum_thread.html
@@ -9,17 +9,26 @@
9 <a href="{% url 'fossil:forum' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to forum</a>
10 </div>
11
12 <div class="space-y-3">
13 {% for post in posts %}
14 <div class="rounded-lg bg-gray-800 border border-gray-700 {% if post.in_reply_to %}ml-8{% endif %}">
15 <div class="px-5 py-4">
16 <div class="flex items-center justify-between mb-2">
17 <span class="text-sm font-medium text-gray-200">{{ post.user }}</span>
18 <span class="text-xs text-gray-500">{{ post.timestamp|timesince }} ago</span>
19 </div>
20 {% if post.title and forloop.first %}
21 <h2 class="text-lg font-semibold text-gray-100 mb-3">{{ post.title }}</h2>
22 {% endif %}
23 {% if post.body %}
24 <div class="prose prose-invert prose-sm prose-gray max-w-none">
25 {{ post.body|linebreaksbr }}
26 </div>
27 {% else %}
28 <p class="text-sm text-gray-500 italic">{{ post.title|default:"(empty)" }}</p>
29 {% endif %}
30 </div>
31 </div>
32 {% empty %}
33 <p class="text-sm text-gray-500 py-8 text-center">No posts in this thread.</p>
34 {% endfor %}
35
--- templates/fossil/partials/file_tree.html
+++ templates/fossil/partials/file_tree.html
@@ -2,13 +2,13 @@
22
{% if tree %}
33
<table class="min-w-full">
44
<tbody class="divide-y divide-gray-700">
55
{% for entry in tree %}
66
<tr class="hover:bg-gray-700/30">
7
- <td class="px-4 py-2 whitespace-nowrap w-64">
7
+ <td class="px-4 py-2 whitespace-nowrap" style="width: 35%;">
88
{% if entry.is_dir %}
9
- <a href="{% url 'fossil:code' slug=project.slug %}" class="flex items-center gap-2 text-sm text-gray-200 hover:text-brand-light">
9
+ <a href="{% url 'fossil:code_dir' slug=project.slug dirpath=entry.path %}" class="flex items-center gap-2 text-sm text-gray-200 hover:text-brand-light">
1010
<svg class="h-4 w-4 text-brand-light flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
1111
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
1212
</svg>
1313
{{ entry.name }}
1414
</a>
@@ -19,11 +19,11 @@
1919
</svg>
2020
{{ entry.name }}
2121
</a>
2222
{% endif %}
2323
</td>
24
- <td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 truncate max-w-md">
24
+ <td class="px-4 py-2 text-sm text-gray-500 truncate max-w-md">
2525
{{ entry.commit_message|truncatechars:60 }}
2626
</td>
2727
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 text-right">
2828
{% if entry.commit_time %}{{ entry.commit_time|timesince }} ago{% endif %}
2929
</td>
@@ -30,8 +30,8 @@
3030
</tr>
3131
{% endfor %}
3232
</tbody>
3333
</table>
3434
{% else %}
35
- <p class="text-sm text-gray-500 py-8 text-center">No files in this repository yet.</p>
35
+ <p class="text-sm text-gray-500 py-8 text-center">No files in this directory.</p>
3636
{% endif %}
3737
</div>
3838
--- templates/fossil/partials/file_tree.html
+++ templates/fossil/partials/file_tree.html
@@ -2,13 +2,13 @@
2 {% if tree %}
3 <table class="min-w-full">
4 <tbody class="divide-y divide-gray-700">
5 {% for entry in tree %}
6 <tr class="hover:bg-gray-700/30">
7 <td class="px-4 py-2 whitespace-nowrap w-64">
8 {% if entry.is_dir %}
9 <a href="{% url 'fossil:code' slug=project.slug %}" class="flex items-center gap-2 text-sm text-gray-200 hover:text-brand-light">
10 <svg class="h-4 w-4 text-brand-light flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
11 <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
12 </svg>
13 {{ entry.name }}
14 </a>
@@ -19,11 +19,11 @@
19 </svg>
20 {{ entry.name }}
21 </a>
22 {% endif %}
23 </td>
24 <td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 truncate max-w-md">
25 {{ entry.commit_message|truncatechars:60 }}
26 </td>
27 <td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 text-right">
28 {% if entry.commit_time %}{{ entry.commit_time|timesince }} ago{% endif %}
29 </td>
@@ -30,8 +30,8 @@
30 </tr>
31 {% endfor %}
32 </tbody>
33 </table>
34 {% else %}
35 <p class="text-sm text-gray-500 py-8 text-center">No files in this repository yet.</p>
36 {% endif %}
37 </div>
38
--- templates/fossil/partials/file_tree.html
+++ templates/fossil/partials/file_tree.html
@@ -2,13 +2,13 @@
2 {% if tree %}
3 <table class="min-w-full">
4 <tbody class="divide-y divide-gray-700">
5 {% for entry in tree %}
6 <tr class="hover:bg-gray-700/30">
7 <td class="px-4 py-2 whitespace-nowrap" style="width: 35%;">
8 {% if entry.is_dir %}
9 <a href="{% url 'fossil:code_dir' slug=project.slug dirpath=entry.path %}" class="flex items-center gap-2 text-sm text-gray-200 hover:text-brand-light">
10 <svg class="h-4 w-4 text-brand-light flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
11 <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
12 </svg>
13 {{ entry.name }}
14 </a>
@@ -19,11 +19,11 @@
19 </svg>
20 {{ entry.name }}
21 </a>
22 {% endif %}
23 </td>
24 <td class="px-4 py-2 text-sm text-gray-500 truncate max-w-md">
25 {{ entry.commit_message|truncatechars:60 }}
26 </td>
27 <td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 text-right">
28 {% if entry.commit_time %}{{ entry.commit_time|timesince }} ago{% endif %}
29 </td>
@@ -30,8 +30,8 @@
30 </tr>
31 {% endfor %}
32 </tbody>
33 </table>
34 {% else %}
35 <p class="text-sm text-gray-500 py-8 text-center">No files in this directory.</p>
36 {% endif %}
37 </div>
38
--- templates/fossil/partials/timeline_entries.html
+++ templates/fossil/partials/timeline_entries.html
@@ -52,21 +52,25 @@
5252
<!-- Content -->
5353
<div class="flex-1 py-1 min-w-0">
5454
<div class="rounded-lg bg-gray-800 border border-gray-700 px-4 py-2.5 hover:border-gray-600 transition-colors">
5555
<div class="flex items-start justify-between gap-3">
5656
<div class="flex-1 min-w-0">
57
+ {% if item.entry.event_type == "ci" %}
58
+ <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=item.entry.uuid %}" class="text-sm text-gray-100 leading-snug hover:text-brand-light">{{ item.entry.comment|default:"(no comment)"|truncatechars:120 }}</a>
59
+ {% else %}
5760
<p class="text-sm text-gray-100 leading-snug">{{ item.entry.comment|default:"(no comment)"|truncatechars:120 }}</p>
61
+ {% endif %}
5862
<div class="mt-1 flex items-center gap-3 flex-wrap">
59
- <span class="text-xs text-gray-500">{{ item.entry.user }}</span>
60
- <span class="text-xs text-gray-600">{{ item.entry.timestamp|timesince }} ago</span>
63
+ <span class="text-xs text-gray-400">{{ item.entry.user }}</span>
64
+ <span class="text-xs text-gray-600">{{ item.entry.timestamp|date:"Y-m-d H:i" }}</span>
6165
{% if item.entry.branch %}
6266
<span class="inline-flex items-center rounded-md bg-brand/10 border border-brand/20 px-1.5 py-0.5 text-xs text-brand-light">
6367
{{ item.entry.branch }}
6468
</span>
6569
{% endif %}
6670
{% if item.entry.event_type == "ci" %}
67
- <code class="text-xs font-mono text-gray-600">{{ item.entry.uuid|truncatechars:10 }}</code>
71
+ <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=item.entry.uuid %}" class="text-xs font-mono text-brand-light hover:text-brand">{{ item.entry.uuid|truncatechars:10 }}</a>
6872
{% endif %}
6973
</div>
7074
</div>
7175
<div class="flex-shrink-0">
7276
{% if item.entry.event_type == "ci" %}
7377
--- templates/fossil/partials/timeline_entries.html
+++ templates/fossil/partials/timeline_entries.html
@@ -52,21 +52,25 @@
52 <!-- Content -->
53 <div class="flex-1 py-1 min-w-0">
54 <div class="rounded-lg bg-gray-800 border border-gray-700 px-4 py-2.5 hover:border-gray-600 transition-colors">
55 <div class="flex items-start justify-between gap-3">
56 <div class="flex-1 min-w-0">
 
 
 
57 <p class="text-sm text-gray-100 leading-snug">{{ item.entry.comment|default:"(no comment)"|truncatechars:120 }}</p>
 
58 <div class="mt-1 flex items-center gap-3 flex-wrap">
59 <span class="text-xs text-gray-500">{{ item.entry.user }}</span>
60 <span class="text-xs text-gray-600">{{ item.entry.timestamp|timesince }} ago</span>
61 {% if item.entry.branch %}
62 <span class="inline-flex items-center rounded-md bg-brand/10 border border-brand/20 px-1.5 py-0.5 text-xs text-brand-light">
63 {{ item.entry.branch }}
64 </span>
65 {% endif %}
66 {% if item.entry.event_type == "ci" %}
67 <code class="text-xs font-mono text-gray-600">{{ item.entry.uuid|truncatechars:10 }}</code>
68 {% endif %}
69 </div>
70 </div>
71 <div class="flex-shrink-0">
72 {% if item.entry.event_type == "ci" %}
73
--- templates/fossil/partials/timeline_entries.html
+++ templates/fossil/partials/timeline_entries.html
@@ -52,21 +52,25 @@
52 <!-- Content -->
53 <div class="flex-1 py-1 min-w-0">
54 <div class="rounded-lg bg-gray-800 border border-gray-700 px-4 py-2.5 hover:border-gray-600 transition-colors">
55 <div class="flex items-start justify-between gap-3">
56 <div class="flex-1 min-w-0">
57 {% if item.entry.event_type == "ci" %}
58 <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=item.entry.uuid %}" class="text-sm text-gray-100 leading-snug hover:text-brand-light">{{ item.entry.comment|default:"(no comment)"|truncatechars:120 }}</a>
59 {% else %}
60 <p class="text-sm text-gray-100 leading-snug">{{ item.entry.comment|default:"(no comment)"|truncatechars:120 }}</p>
61 {% endif %}
62 <div class="mt-1 flex items-center gap-3 flex-wrap">
63 <span class="text-xs text-gray-400">{{ item.entry.user }}</span>
64 <span class="text-xs text-gray-600">{{ item.entry.timestamp|date:"Y-m-d H:i" }}</span>
65 {% if item.entry.branch %}
66 <span class="inline-flex items-center rounded-md bg-brand/10 border border-brand/20 px-1.5 py-0.5 text-xs text-brand-light">
67 {{ item.entry.branch }}
68 </span>
69 {% endif %}
70 {% if item.entry.event_type == "ci" %}
71 <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=item.entry.uuid %}" class="text-xs font-mono text-brand-light hover:text-brand">{{ item.entry.uuid|truncatechars:10 }}</a>
72 {% endif %}
73 </div>
74 </div>
75 <div class="flex-shrink-0">
76 {% if item.entry.event_type == "ci" %}
77
--- templates/fossil/ticket_detail.html
+++ templates/fossil/ticket_detail.html
@@ -9,40 +9,61 @@
99
<a href="{% url 'fossil:tickets' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to tickets</a>
1010
</div>
1111
1212
<div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
1313
<div class="px-6 py-5 border-b border-gray-700">
14
- <h2 class="text-xl font-bold text-gray-100">{{ ticket.title|default:"(untitled)" }}</h2>
15
- <p class="mt-1 text-sm text-gray-400">
16
- <code class="font-mono text-xs text-gray-500">{{ ticket.uuid|truncatechars:16 }}</code>
14
+ <div class="flex items-start justify-between gap-4">
15
+ <h2 class="text-xl font-bold text-gray-100">{{ ticket.title|default:"(untitled)" }}</h2>
16
+ {% if ticket.status == "Open" %}
17
+ <span class="inline-flex rounded-full bg-green-900/50 px-3 py-1 text-xs font-semibold text-green-300 flex-shrink-0">{{ ticket.status }}</span>
18
+ {% elif ticket.status == "Closed" or ticket.status == "Fixed" %}
19
+ <span class="inline-flex rounded-full bg-gray-700 px-3 py-1 text-xs font-semibold text-gray-300 flex-shrink-0">{{ ticket.status }}</span>
20
+ {% else %}
21
+ <span class="inline-flex rounded-full bg-yellow-900/50 px-3 py-1 text-xs font-semibold text-yellow-300 flex-shrink-0">{{ ticket.status }}</span>
22
+ {% endif %}
23
+ </div>
24
+ <p class="mt-1 text-xs text-gray-500">
25
+ <code class="font-mono">{{ ticket.uuid|truncatechars:16 }}</code>
26
+ &middot; opened {{ ticket.created|timesince }} ago
1727
</p>
1828
</div>
19
- <div class="px-6 py-5">
20
- <dl class="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-3">
21
- <div>
22
- <dt class="text-sm font-medium text-gray-400">Status</dt>
23
- <dd class="mt-1 text-sm text-gray-100">{{ ticket.status|default:"—" }}</dd>
24
- </div>
25
- <div>
26
- <dt class="text-sm font-medium text-gray-400">Type</dt>
27
- <dd class="mt-1 text-sm text-gray-100">{{ ticket.type|default:"—" }}</dd>
28
- </div>
29
- <div>
30
- <dt class="text-sm font-medium text-gray-400">Owner</dt>
31
- <dd class="mt-1 text-sm text-gray-100">{{ ticket.owner|default:"—" }}</dd>
32
- </div>
33
- <div>
34
- <dt class="text-sm font-medium text-gray-400">Priority</dt>
35
- <dd class="mt-1 text-sm text-gray-100">{{ ticket.priority|default:"—" }}</dd>
36
- </div>
37
- <div>
38
- <dt class="text-sm font-medium text-gray-400">Subsystem</dt>
39
- <dd class="mt-1 text-sm text-gray-100">{{ ticket.subsystem|default:"—" }}</dd>
40
- </div>
41
- <div>
42
- <dt class="text-sm font-medium text-gray-400">Created</dt>
43
- <dd class="mt-1 text-sm text-gray-100">{{ ticket.created|date:"N j, Y g:i a" }}</dd>
29
+
30
+ <!-- Metadata grid -->
31
+ <div class="px-6 py-4 border-b border-gray-700">
32
+ <dl class="grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-4">
33
+ <div>
34
+ <dt class="text-xs font-medium text-gray-500 uppercase">Type</dt>
35
+ <dd class="mt-0.5 text-sm text-gray-200">{{ ticket.type|default:"—" }}</dd>
36
+ </div>
37
+ <div>
38
+ <dt class="text-xs font-medium text-gray-500 uppercase">Priority</dt>
39
+ <dd class="mt-0.5 text-sm text-gray-200">{{ ticket.priority|default:"—" }}</dd>
40
+ </div>
41
+ <div>
42
+ <dt class="text-xs font-medium text-gray-500 uppercase">Severity</dt>
43
+ <dd class="mt-0.5 text-sm text-gray-200">{{ ticket.severity|default:"—" }}</dd>
44
+ </div>
45
+ <div>
46
+ <dt class="text-xs font-medium text-gray-500 uppercase">Resolution</dt>
47
+ <dd class="mt-0.5 text-sm text-gray-200">{{ ticket.resolution|default:"—" }}</dd>
48
+ </div>
49
+ <div>
50
+ <dt class="text-xs font-medium text-gray-500 uppercase">Subsystem</dt>
51
+ <dd class="mt-0.5 text-sm text-gray-200">{{ ticket.subsystem|default:"—" }}</dd>
52
+ </div>
53
+ <div>
54
+ <dt class="text-xs font-medium text-gray-500 uppercase">Created</dt>
55
+ <dd class="mt-0.5 text-sm text-gray-200">{{ ticket.created|date:"N j, Y g:i a" }}</dd>
4456
</div>
4557
</dl>
4658
</div>
59
+
60
+ <!-- Body/description -->
61
+ {% if ticket.body %}
62
+ <div class="px-6 py-5">
63
+ <div class="prose prose-invert prose-gray prose-sm max-w-none">
64
+ {{ ticket.body|linebreaksbr }}
65
+ </div>
66
+ </div>
67
+ {% endif %}
4768
</div>
4869
{% endblock %}
4970
--- templates/fossil/ticket_detail.html
+++ templates/fossil/ticket_detail.html
@@ -9,40 +9,61 @@
9 <a href="{% url 'fossil:tickets' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to tickets</a>
10 </div>
11
12 <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
13 <div class="px-6 py-5 border-b border-gray-700">
14 <h2 class="text-xl font-bold text-gray-100">{{ ticket.title|default:"(untitled)" }}</h2>
15 <p class="mt-1 text-sm text-gray-400">
16 <code class="font-mono text-xs text-gray-500">{{ ticket.uuid|truncatechars:16 }}</code>
 
 
 
 
 
 
 
 
 
 
17 </p>
18 </div>
19 <div class="px-6 py-5">
20 <dl class="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-3">
21 <div>
22 <dt class="text-sm font-medium text-gray-400">Status</dt>
23 <dd class="mt-1 text-sm text-gray-100">{{ ticket.status|default:"—" }}</dd>
24 </div>
25 <div>
26 <dt class="text-sm font-medium text-gray-400">Type</dt>
27 <dd class="mt-1 text-sm text-gray-100">{{ ticket.type|default:"—" }}</dd>
28 </div>
29 <div>
30 <dt class="text-sm font-medium text-gray-400">Owner</dt>
31 <dd class="mt-1 text-sm text-gray-100">{{ ticket.owner|default:"—" }}</dd>
32 </div>
33 <div>
34 <dt class="text-sm font-medium text-gray-400">Priority</dt>
35 <dd class="mt-1 text-sm text-gray-100">{{ ticket.priority|default:"—" }}</dd>
36 </div>
37 <div>
38 <dt class="text-sm font-medium text-gray-400">Subsystem</dt>
39 <dd class="mt-1 text-sm text-gray-100">{{ ticket.subsystem|default:"—" }}</dd>
40 </div>
41 <div>
42 <dt class="text-sm font-medium text-gray-400">Created</dt>
43 <dd class="mt-1 text-sm text-gray-100">{{ ticket.created|date:"N j, Y g:i a" }}</dd>
 
 
44 </div>
45 </dl>
46 </div>
 
 
 
 
 
 
 
 
 
47 </div>
48 {% endblock %}
49
--- templates/fossil/ticket_detail.html
+++ templates/fossil/ticket_detail.html
@@ -9,40 +9,61 @@
9 <a href="{% url 'fossil:tickets' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to tickets</a>
10 </div>
11
12 <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
13 <div class="px-6 py-5 border-b border-gray-700">
14 <div class="flex items-start justify-between gap-4">
15 <h2 class="text-xl font-bold text-gray-100">{{ ticket.title|default:"(untitled)" }}</h2>
16 {% if ticket.status == "Open" %}
17 <span class="inline-flex rounded-full bg-green-900/50 px-3 py-1 text-xs font-semibold text-green-300 flex-shrink-0">{{ ticket.status }}</span>
18 {% elif ticket.status == "Closed" or ticket.status == "Fixed" %}
19 <span class="inline-flex rounded-full bg-gray-700 px-3 py-1 text-xs font-semibold text-gray-300 flex-shrink-0">{{ ticket.status }}</span>
20 {% else %}
21 <span class="inline-flex rounded-full bg-yellow-900/50 px-3 py-1 text-xs font-semibold text-yellow-300 flex-shrink-0">{{ ticket.status }}</span>
22 {% endif %}
23 </div>
24 <p class="mt-1 text-xs text-gray-500">
25 <code class="font-mono">{{ ticket.uuid|truncatechars:16 }}</code>
26 &middot; opened {{ ticket.created|timesince }} ago
27 </p>
28 </div>
29
30 <!-- Metadata grid -->
31 <div class="px-6 py-4 border-b border-gray-700">
32 <dl class="grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-4">
33 <div>
34 <dt class="text-xs font-medium text-gray-500 uppercase">Type</dt>
35 <dd class="mt-0.5 text-sm text-gray-200">{{ ticket.type|default:"—" }}</dd>
36 </div>
37 <div>
38 <dt class="text-xs font-medium text-gray-500 uppercase">Priority</dt>
39 <dd class="mt-0.5 text-sm text-gray-200">{{ ticket.priority|default:"—" }}</dd>
40 </div>
41 <div>
42 <dt class="text-xs font-medium text-gray-500 uppercase">Severity</dt>
43 <dd class="mt-0.5 text-sm text-gray-200">{{ ticket.severity|default:"—" }}</dd>
44 </div>
45 <div>
46 <dt class="text-xs font-medium text-gray-500 uppercase">Resolution</dt>
47 <dd class="mt-0.5 text-sm text-gray-200">{{ ticket.resolution|default:"—" }}</dd>
48 </div>
49 <div>
50 <dt class="text-xs font-medium text-gray-500 uppercase">Subsystem</dt>
51 <dd class="mt-0.5 text-sm text-gray-200">{{ ticket.subsystem|default:"—" }}</dd>
52 </div>
53 <div>
54 <dt class="text-xs font-medium text-gray-500 uppercase">Created</dt>
55 <dd class="mt-0.5 text-sm text-gray-200">{{ ticket.created|date:"N j, Y g:i a" }}</dd>
56 </div>
57 </dl>
58 </div>
59
60 <!-- Body/description -->
61 {% if ticket.body %}
62 <div class="px-6 py-5">
63 <div class="prose prose-invert prose-gray prose-sm max-w-none">
64 {{ ticket.body|linebreaksbr }}
65 </div>
66 </div>
67 {% endif %}
68 </div>
69 {% endblock %}
70

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button