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
addc7d8f93a5090ae46adb25b1beda52f0613636ea392dc7d8e58ca43a2e066f
| --- fossil/reader.py | ||
| +++ fossil/reader.py | ||
| @@ -35,10 +35,26 @@ | ||
| 35 | 35 | is_dir: bool = False |
| 36 | 36 | last_commit_message: str = "" |
| 37 | 37 | last_commit_user: str = "" |
| 38 | 38 | last_commit_time: datetime | None = None |
| 39 | 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 | + | |
| 40 | 56 | |
| 41 | 57 | @dataclass |
| 42 | 58 | class TicketEntry: |
| 43 | 59 | uuid: str |
| 44 | 60 | title: str |
| @@ -46,10 +62,13 @@ | ||
| 46 | 62 | type: str |
| 47 | 63 | created: datetime |
| 48 | 64 | owner: str |
| 49 | 65 | subsystem: str = "" |
| 50 | 66 | priority: str = "" |
| 67 | + severity: str = "" | |
| 68 | + resolution: str = "" | |
| 69 | + body: str = "" # main comment/description | |
| 51 | 70 | |
| 52 | 71 | |
| 53 | 72 | @dataclass |
| 54 | 73 | class WikiPage: |
| 55 | 74 | name: str |
| @@ -116,17 +135,20 @@ | ||
| 116 | 135 | |
| 117 | 136 | def _extract_wiki_content(artifact_text: str) -> str: |
| 118 | 137 | """Extract wiki body from a Fossil wiki artifact. |
| 119 | 138 | |
| 120 | 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. | |
| 121 | 141 | """ |
| 122 | 142 | import re |
| 123 | 143 | |
| 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] | |
| 128 | 150 | |
| 129 | 151 | |
| 130 | 152 | class FossilReader: |
| 131 | 153 | """Read-only interface to a .fossil SQLite database.""" |
| 132 | 154 | |
| @@ -230,13 +252,11 @@ | ||
| 230 | 252 | pass |
| 231 | 253 | |
| 232 | 254 | # Get parent info from plink for DAG |
| 233 | 255 | if row["type"] == "ci": |
| 234 | 256 | 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() | |
| 238 | 258 | for p in parents: |
| 239 | 259 | if p["isprim"]: |
| 240 | 260 | parent_rid = p["pid"] |
| 241 | 261 | is_merge = len(parents) > 1 |
| 242 | 262 | except sqlite3.OperationalError: |
| @@ -270,10 +290,101 @@ | ||
| 270 | 290 | branch_rails[b] = next_rail |
| 271 | 291 | next_rail += 1 |
| 272 | 292 | entry.rail = branch_rails[b] |
| 273 | 293 | |
| 274 | 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 | |
| 275 | 386 | |
| 276 | 387 | # --- Code / Files --- |
| 277 | 388 | |
| 278 | 389 | def get_latest_checkin_uuid(self) -> str | None: |
| 279 | 390 | try: |
| @@ -369,11 +480,11 @@ | ||
| 369 | 480 | return entries |
| 370 | 481 | |
| 371 | 482 | def get_ticket_detail(self, uuid: str) -> TicketEntry | None: |
| 372 | 483 | try: |
| 373 | 484 | 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 " | |
| 375 | 486 | "FROM ticket WHERE tkt_uuid LIKE ?", |
| 376 | 487 | (uuid + "%",), |
| 377 | 488 | ).fetchone() |
| 378 | 489 | if not row: |
| 379 | 490 | return None |
| @@ -384,10 +495,13 @@ | ||
| 384 | 495 | type=row["type"] or "", |
| 385 | 496 | created=_julian_to_datetime(row["tkt_ctime"]) if row["tkt_ctime"] else datetime.now(UTC), |
| 386 | 497 | owner="", |
| 387 | 498 | subsystem=row["subsystem"] or "", |
| 388 | 499 | priority=row["priority"] or "", |
| 500 | + severity=row["severity"] or "", | |
| 501 | + resolution=row["resolution"] or "", | |
| 502 | + body=row["comment"] or "", | |
| 389 | 503 | ) |
| 390 | 504 | except sqlite3.OperationalError: |
| 391 | 505 | return None |
| 392 | 506 | |
| 393 | 507 | # --- Wiki --- |
| @@ -456,58 +570,83 @@ | ||
| 456 | 570 | return None |
| 457 | 571 | |
| 458 | 572 | # --- Forum --- |
| 459 | 573 | |
| 460 | 574 | def get_forum_posts(self, limit: int = 50) -> list[ForumPost]: |
| 575 | + """Get root forum posts (thread starters) with body content.""" | |
| 461 | 576 | posts = [] |
| 462 | 577 | try: |
| 463 | 578 | rows = self.conn.execute( |
| 464 | 579 | """ |
| 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 | |
| 470 | 586 | LIMIT ? |
| 471 | 587 | """, |
| 472 | 588 | (limit,), |
| 473 | 589 | ).fetchall() |
| 474 | 590 | for row in rows: |
| 591 | + body = self._read_forum_body(row["rid"]) | |
| 475 | 592 | posts.append( |
| 476 | 593 | ForumPost( |
| 477 | 594 | uuid=row["uuid"], |
| 478 | 595 | title=row["comment"] or "", |
| 479 | - body="", | |
| 480 | - timestamp=_julian_to_datetime(row["mtime"]), | |
| 596 | + body=body, | |
| 597 | + timestamp=_julian_to_datetime(row["fmtime"]), | |
| 481 | 598 | user=row["user"] or "", |
| 482 | 599 | ) |
| 483 | 600 | ) |
| 484 | 601 | except sqlite3.OperationalError: |
| 485 | 602 | pass |
| 486 | 603 | return posts |
| 487 | 604 | |
| 488 | 605 | 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.""" | |
| 490 | 607 | posts = [] |
| 491 | 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 | + | |
| 492 | 615 | rows = self.conn.execute( |
| 493 | 616 | """ |
| 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,), | |
| 500 | 625 | ).fetchall() |
| 501 | 626 | for row in rows: |
| 627 | + body = self._read_forum_body(row["rid"]) | |
| 502 | 628 | posts.append( |
| 503 | 629 | ForumPost( |
| 504 | 630 | uuid=row["uuid"], |
| 505 | 631 | title=row["comment"] or "", |
| 506 | - body="", | |
| 507 | - timestamp=_julian_to_datetime(row["mtime"]), | |
| 632 | + body=body, | |
| 633 | + timestamp=_julian_to_datetime(row["fmtime"]), | |
| 508 | 634 | user=row["user"] or "", |
| 635 | + in_reply_to=str(row["firt"]) if row["firt"] else "", | |
| 509 | 636 | ) |
| 510 | 637 | ) |
| 511 | 638 | except sqlite3.OperationalError: |
| 512 | 639 | pass |
| 513 | 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 "" | |
| 514 | 653 |
| --- 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 |
| --- fossil/urls.py | ||
| +++ fossil/urls.py | ||
| @@ -4,12 +4,14 @@ | ||
| 4 | 4 | |
| 5 | 5 | app_name = "fossil" |
| 6 | 6 | |
| 7 | 7 | urlpatterns = [ |
| 8 | 8 | 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"), | |
| 10 | 11 | path("timeline/", views.timeline, name="timeline"), |
| 12 | + path("checkin/<str:checkin_uuid>/", views.checkin_detail, name="checkin_detail"), | |
| 11 | 13 | path("tickets/", views.ticket_list, name="tickets"), |
| 12 | 14 | path("tickets/<str:ticket_uuid>/", views.ticket_detail, name="ticket_detail"), |
| 13 | 15 | path("wiki/", views.wiki_list, name="wiki"), |
| 14 | 16 | path("wiki/page/<path:page_name>", views.wiki_page, name="wiki_page"), |
| 15 | 17 | path("forum/", views.forum_list, name="forum"), |
| 16 | 18 |
| --- 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 |
| --- fossil/views.py | ||
| +++ fossil/views.py | ||
| @@ -23,33 +23,42 @@ | ||
| 23 | 23 | |
| 24 | 24 | # --- Code Browser --- |
| 25 | 25 | |
| 26 | 26 | |
| 27 | 27 | @login_required |
| 28 | -def code_browser(request, slug): | |
| 28 | +def code_browser(request, slug, dirpath=""): | |
| 29 | 29 | P.PROJECT_VIEW.check(request.user) |
| 30 | 30 | project, fossil_repo, reader = _get_repo_and_reader(slug) |
| 31 | 31 | |
| 32 | 32 | with reader: |
| 33 | 33 | checkin_uuid = reader.get_latest_checkin_uuid() |
| 34 | 34 | files = reader.get_files_at_checkin(checkin_uuid) if checkin_uuid else [] |
| 35 | 35 | metadata = reader.get_metadata() |
| 36 | 36 | latest_commit = reader.get_timeline(limit=1, event_type="ci") |
| 37 | 37 | |
| 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])}) | |
| 40 | 47 | |
| 41 | 48 | 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}) | |
| 43 | 50 | |
| 44 | 51 | return render( |
| 45 | 52 | request, |
| 46 | 53 | "fossil/code_browser.html", |
| 47 | 54 | { |
| 48 | 55 | "project": project, |
| 49 | 56 | "fossil_repo": fossil_repo, |
| 50 | 57 | "tree": tree, |
| 58 | + "current_dir": dirpath, | |
| 59 | + "breadcrumbs": breadcrumbs, | |
| 51 | 60 | "checkin_uuid": checkin_uuid, |
| 52 | 61 | "metadata": metadata, |
| 53 | 62 | "latest_commit": latest_commit[0] if latest_commit else None, |
| 54 | 63 | "active_tab": "code", |
| 55 | 64 | }, |
| @@ -86,24 +95,57 @@ | ||
| 86 | 95 | is_binary = True |
| 87 | 96 | |
| 88 | 97 | # Determine language for syntax highlighting |
| 89 | 98 | ext = filepath.rsplit(".", 1)[-1] if "." in filepath else "" |
| 90 | 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 | + | |
| 91 | 106 | return render( |
| 92 | 107 | request, |
| 93 | 108 | "fossil/code_file.html", |
| 94 | 109 | { |
| 95 | 110 | "project": project, |
| 96 | 111 | "fossil_repo": fossil_repo, |
| 97 | 112 | "filepath": filepath, |
| 113 | + "file_breadcrumbs": file_breadcrumbs, | |
| 98 | 114 | "content": content, |
| 99 | 115 | "is_binary": is_binary, |
| 100 | 116 | "language": ext, |
| 101 | 117 | "active_tab": "code", |
| 102 | 118 | }, |
| 103 | 119 | ) |
| 104 | 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 | + | |
| 105 | 147 | |
| 106 | 148 | # --- Timeline --- |
| 107 | 149 | |
| 108 | 150 | |
| 109 | 151 | @login_required |
| @@ -302,52 +344,63 @@ | ||
| 302 | 344 | |
| 303 | 345 | |
| 304 | 346 | # --- Helpers --- |
| 305 | 347 | |
| 306 | 348 | |
| 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. | |
| 309 | 351 | |
| 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. | |
| 312 | 354 | """ |
| 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 | |
| 315 | 360 | |
| 316 | 361 | for f in files: |
| 317 | 362 | # Skip files with characters that break URL routing |
| 318 | 363 | if "\n" in f.name or "\r" in f.name or "\x00" in f.name: |
| 319 | 364 | 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 | + | |
| 321 | 372 | 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) | |
| 326 | 377 | ): |
| 327 | - dirs[dir_name] = f | |
| 378 | + dirs[child_dir] = f | |
| 328 | 379 | else: |
| 329 | - root_files.append(f) | |
| 380 | + dir_files.append(f) | |
| 330 | 381 | |
| 331 | 382 | entries = [] |
| 332 | 383 | # Directories first (sorted) |
| 333 | 384 | for dir_name in sorted(dirs): |
| 334 | 385 | f = dirs[dir_name] |
| 386 | + full_dir_path = (prefix + dir_name) if prefix else dir_name | |
| 335 | 387 | entries.append( |
| 336 | 388 | { |
| 337 | 389 | "name": dir_name, |
| 338 | - "path": dir_name, | |
| 390 | + "path": full_dir_path, | |
| 339 | 391 | "is_dir": True, |
| 340 | 392 | "commit_message": f.last_commit_message, |
| 341 | 393 | "commit_time": f.last_commit_time, |
| 342 | 394 | } |
| 343 | 395 | ) |
| 344 | 396 | # 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 | |
| 346 | 399 | entries.append( |
| 347 | 400 | { |
| 348 | - "name": f.name, | |
| 401 | + "name": filename, | |
| 349 | 402 | "path": f.name, |
| 350 | 403 | "is_dir": False, |
| 351 | 404 | "file": f, |
| 352 | 405 | "commit_message": f.last_commit_message, |
| 353 | 406 | "commit_time": f.last_commit_time, |
| 354 | 407 | |
| 355 | 408 | 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 @@ | ||
| 1 | 1 | {% extends "base.html" %} |
| 2 | 2 | {% load humanize %} |
| 3 | -{% block title %}Code — {{ project.name }} — Fossilrepo{% endblock %} | |
| 3 | +{% block title %}{% if current_dir %}{{ current_dir }} — {% endif %}Code — {{ project.name }} — Fossilrepo{% endblock %} | |
| 4 | 4 | |
| 5 | 5 | {% block content %} |
| 6 | 6 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 7 | 7 | {% include "fossil/_project_nav.html" %} |
| 8 | 8 | |
| 9 | 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"> | |
| 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"> | |
| 22 | 28 | <path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" /> |
| 23 | 29 | </svg> |
| 24 | 30 | {{ 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> | |
| 26 | 42 | </div> |
| 43 | + {% endif %} | |
| 27 | 44 | </div> |
| 28 | - {% endif %} | |
| 29 | 45 | |
| 30 | 46 | <!-- File table --> |
| 31 | 47 | {% include "fossil/partials/file_tree.html" %} |
| 32 | 48 | </div> |
| 33 | 49 | {% endblock %} |
| 34 | 50 |
| --- 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 @@ | ||
| 8 | 8 | |
| 9 | 9 | {% block content %} |
| 10 | 10 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 11 | 11 | {% include "fossil/_project_nav.html" %} |
| 12 | 12 | |
| 13 | -<div class="mb-4"> | |
| 14 | - <a href="{% url 'fossil:code' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to files</a> | |
| 15 | -</div> | |
| 16 | - | |
| 17 | 13 | <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700"> |
| 14 | + <!-- File path breadcrumb --> | |
| 18 | 15 | <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> | |
| 20 | 27 | </div> |
| 21 | 28 | <div class="overflow-x-auto"> |
| 22 | 29 | {% if is_binary %} |
| 23 | 30 | <p class="p-4 text-sm text-gray-500">{{ content }}</p> |
| 24 | 31 | {% else %} |
| 25 | 32 |
| --- 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">← 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 @@ | ||
| 3 | 3 | |
| 4 | 4 | {% block content %} |
| 5 | 5 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 6 | 6 | {% include "fossil/_project_nav.html" %} |
| 7 | 7 | |
| 8 | -<div class="space-y-2"> | |
| 8 | +<div class="space-y-3"> | |
| 9 | 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 }} · {{ 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> | |
| 17 | 23 | </div> |
| 18 | 24 | </div> |
| 19 | 25 | {% empty %} |
| 20 | 26 | <p class="text-sm text-gray-500 py-8 text-center">No forum posts.</p> |
| 21 | 27 | {% endfor %} |
| 22 | 28 |
| --- 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 }} · {{ 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 @@ | ||
| 9 | 9 | <a href="{% url 'fossil:forum' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to forum</a> |
| 10 | 10 | </div> |
| 11 | 11 | |
| 12 | 12 | <div class="space-y-3"> |
| 13 | 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)" }} | |
| 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 %} | |
| 21 | 30 | </div> |
| 22 | 31 | </div> |
| 23 | 32 | {% empty %} |
| 24 | 33 | <p class="text-sm text-gray-500 py-8 text-center">No posts in this thread.</p> |
| 25 | 34 | {% endfor %} |
| 26 | 35 |
| --- 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">← 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">← 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 @@ | ||
| 2 | 2 | {% if tree %} |
| 3 | 3 | <table class="min-w-full"> |
| 4 | 4 | <tbody class="divide-y divide-gray-700"> |
| 5 | 5 | {% for entry in tree %} |
| 6 | 6 | <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%;"> | |
| 8 | 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"> | |
| 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 | 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 | 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 | 12 | </svg> |
| 13 | 13 | {{ entry.name }} |
| 14 | 14 | </a> |
| @@ -19,11 +19,11 @@ | ||
| 19 | 19 | </svg> |
| 20 | 20 | {{ entry.name }} |
| 21 | 21 | </a> |
| 22 | 22 | {% endif %} |
| 23 | 23 | </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"> | |
| 25 | 25 | {{ entry.commit_message|truncatechars:60 }} |
| 26 | 26 | </td> |
| 27 | 27 | <td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 text-right"> |
| 28 | 28 | {% if entry.commit_time %}{{ entry.commit_time|timesince }} ago{% endif %} |
| 29 | 29 | </td> |
| @@ -30,8 +30,8 @@ | ||
| 30 | 30 | </tr> |
| 31 | 31 | {% endfor %} |
| 32 | 32 | </tbody> |
| 33 | 33 | </table> |
| 34 | 34 | {% 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> | |
| 36 | 36 | {% endif %} |
| 37 | 37 | </div> |
| 38 | 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 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 @@ | ||
| 52 | 52 | <!-- Content --> |
| 53 | 53 | <div class="flex-1 py-1 min-w-0"> |
| 54 | 54 | <div class="rounded-lg bg-gray-800 border border-gray-700 px-4 py-2.5 hover:border-gray-600 transition-colors"> |
| 55 | 55 | <div class="flex items-start justify-between gap-3"> |
| 56 | 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 %} | |
| 57 | 60 | <p class="text-sm text-gray-100 leading-snug">{{ item.entry.comment|default:"(no comment)"|truncatechars:120 }}</p> |
| 61 | + {% endif %} | |
| 58 | 62 | <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> | |
| 61 | 65 | {% if item.entry.branch %} |
| 62 | 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"> |
| 63 | 67 | {{ item.entry.branch }} |
| 64 | 68 | </span> |
| 65 | 69 | {% endif %} |
| 66 | 70 | {% 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> | |
| 68 | 72 | {% endif %} |
| 69 | 73 | </div> |
| 70 | 74 | </div> |
| 71 | 75 | <div class="flex-shrink-0"> |
| 72 | 76 | {% if item.entry.event_type == "ci" %} |
| 73 | 77 |
| --- 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 @@ | ||
| 9 | 9 | <a href="{% url 'fossil:tickets' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to tickets</a> |
| 10 | 10 | </div> |
| 11 | 11 | |
| 12 | 12 | <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700"> |
| 13 | 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> | |
| 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 | + · opened {{ ticket.created|timesince }} ago | |
| 17 | 27 | </p> |
| 18 | 28 | </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> | |
| 44 | 56 | </div> |
| 45 | 57 | </dl> |
| 46 | 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 %} | |
| 47 | 68 | </div> |
| 48 | 69 | {% endblock %} |
| 49 | 70 |
| --- 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">← 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">← 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 | · 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 |