╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ Threads.zig ──────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
556 if (data.tag.len != 0) 557 try items.append(allocator, .{ .text = "✕", .link = try listLink(session.page_arena, data.identity, @enumFromInt(0), "", "") }); 558 for (data.tags) |tag| 559 try items.append(allocator, .{ .text = tag, .link = try tagLink(session.page_arena, data.identity, @enumFromInt(0), tag) }); 560 try tf.setItems(allocator, items.items); 561 try stack.children.put(allocator, tf.getFocus().id, .{ .tag_flow = tf }); 562 } 563 564 // the new-thread form, or — on an edit, comment, or resolve url — that form in 565 // its place, prefilled with the selected thread's content. a 566 // logged-out session can't create events, so the unauthorized view 567 // stands in. 568 if (session.data.is_local or session.data.user_id != null) { 569 if (data.view == .remove) { 570 const aa = session.page_arena.allocator(); 571 const route = ui.RoutablePage.repoThreadRemoveRoute(kind, data.identity, data.selected_id, data.comment_id) orelse return error.RouteTooLong; 572 const action = try std.fmt.allocPrint(aa, "form:{s}", .{try route.toUrl(session.page_arena)}); 573 const event_name = if (data.comment_id.len == 0) thread_name else "comment"; 574 var label_buf: ["remove ".len + @max(thread_name.len, "comment".len)]u8 = undefined; 575 const label = try std.fmt.bufPrint(&label_buf, "remove {s}", .{event_name}); 576 var center = try initRemoveForm(allocator, action, label); 577 errdefer center.deinit(allocator); 578 try stack.children.put(allocator, center.getFocus().id, .{ .center = center }); 579 } else if (data.view == .new_comment or data.view == .edit_comment) { 580 const editing = data.view == .edit_comment; 581 const route = if (editing) 582 ui.RoutablePage.repoThreadCommentEditRoute(kind, data.identity, data.selected_id, data.comment_id) orelse return error.RouteTooLong 583 else 584 ui.RoutablePage.repoThreadCommentNewRoute(kind, data.identity, data.selected_id, data.comment_id) orelse return error.RouteTooLong; 585 const page_url = try route.toUrl(session.page_arena); 586 const action = try std.fmt.allocPrint(session.page_arena.allocator(), "form:{s}", .{page_url}); 587 const page = data.comment_page; 588 const top_level = if (page) |p| std.mem.eql(u8, &p.selected.comment.event.parent_id, &p.selected.comment.event.thread_id) else false; 589 const parent_route = if (editing) 590 if (top_level) 591 ui.RoutablePage.repoThreadCommentsRoute(kind, data.identity, data.selected_id, 0) 592 else if (page) |p| 593 ui.RoutablePage.repoThreadCommentRoute(kind, data.identity, data.selected_id, &p.selected.comment.event.parent_id, 0) 594 else 595 null 596 else if (page) |p| 597 ui.RoutablePage.repoThreadCommentRoute(kind, data.identity, data.selected_id, &p.selected.id, 0) 598 else 599 ui.RoutablePage.repoThreadCommentsRoute(kind, data.identity, data.selected_id, 0); 600 const parent_author: ui.Author = if (editing) 601 if (top_level) 602 if (data.selectedThread()) |entry| entry.author else .unknown 603 else if (page) |p| 604 p.selected.parent_author orelse .unknown 605 else 606 .unknown 607 else if (page) |p| 608 p.selected.author 609 else if (data.selectedThread()) |entry| 610 entry.author 611 else 612 .unknown; 613 const initial_body: ?[]const u8 = if (editing) if (page) |p| p.selected.comment.event.body else null else null; 614 var form = try initCommentForm(allocator, session, action, parent_author, parent_route orelse return error.RouteTooLong, initial_body); 615 errdefer form.deinit(allocator); 616 try stack.children.put(allocator, form.getFocus().id, .{ .box = form }); 617 } else if (has_conflicts and data.view == .resolve) { 618 // on the web the page grows to the form's height and the 619 // browser scrolls it; the terminal scrolls the form itself 620 var form_widget: ui.Widget = blk: { 621 var form = try initResolveForm(allocator, session, data); 622 errdefer form.deinit(allocator); 623 break :blk if (session.is_terminal) 624 .{ .scroll = try wgt.Scroll(ui.Widget).init(allocator, .{ .box = form }, .{ .direction = .vert }) } 625 else 626 .{ .box = form }; 627 }; 628 errdefer form_widget.deinit(allocator); 629 try stack.children.put(allocator, form_widget.getFocus().id, form_widget); 630 } else { 631 const aa = session.page_arena.allocator(); 632 const route = if (data.view == .edit) 633 ui.RoutablePage.repoThreadEditRoute(kind, data.identity, data.selected_id) orelse return error.RouteTooLong 634 else 635 ui.RoutablePage.repoThreadNewRoute(kind, data.identity) orelse return error.RouteTooLong; 636 const action = try std.fmt.allocPrint(aa, "form:{s}", .{try route.toUrl(session.page_arena)}); 637 const record = if (data.view == .edit) 638 (if (data.selectedThread()) |entry| &entry.record else null) 639 else 640 null; 641 var form = try initThreadForm(allocator, session, action, record); 642 errdefer form.deinit(allocator); 643 try stack.children.put(allocator, form.getFocus().id, .{ .box = form }); 644 } 645 } else { 646 var message = try ui.Unauthorized.View.init(allocator); 647 errdefer message.deinit(allocator); 648 try stack.children.put(allocator, message.getFocus().id, .{ .unauthorized = message }); 649 } 650 651 if (has_drafts) { 652 var split = try initSplit(allocator, session, data, null, true); 653 errdefer split.deinit(allocator); 654 try stack.children.put(allocator, split.getFocus().id, .{ .box = split }); 655 } 656 657 // the conflicts split; its tab exists only when the repo has 658 // conflicts, so skip the child too to keep the stack 1:1 with the 659 // tabs. 660 if (has_conflicts and data.conflicts.count > 0) { 661 var split = try initSplit(allocator, session, data, null, false); 662 errdefer split.deinit(allocator); 663 try stack.children.put(allocator, split.getFocus().id, .{ .box = split }); 664 } 665 666 // the stack starts on the page's view. 667 stack.getFocus().child_id = stack.children.keys()[viewIndex(data.view)]; 668 669 // focus entering the view lands on the tabs first. 670 outer.getFocus().child_id = outer.children.keys()[header_index]; 671 672 return .{ 673 .box = outer, 674 .data = data, 675 .session = session, 676 .detailed_index = @splat(null), 677 .title_id = @splat(null), 678 .description_id = @splat(null), 679 .author_id = @splat(null), 680 }; 681 } 682 683 // the master-detail split showing `status`'s window, or the conflicts 684 // window when null. 685 fn initSplit(allocator: std.mem.Allocator, session: *ui.Session, data: *const Self, status_maybe: ?Status, drafts: bool) !wgt.Box(ui.Widget) { 686 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 687 errdefer box.deinit(allocator); 688 689 const win = if (status_maybe) |status| data.window(status) else if (drafts and has_drafts) &data.drafts else if (has_conflicts) &data.conflicts else unreachable; 690 691 // the thread list (one focusable row per title), plus a "next" link that 692 // reloads the page rooted at the following thread. 693 { 694 var list_scroll = blk: { 695 var list_box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert, .stretch = true }); 696 errdefer list_box.deinit(allocator); 697 if (win.prev_id) |prev| 698 try addRow(allocator, &list_box, "← previous", "", try windowLink(session.page_arena, data, status_maybe, drafts, prev)); 699 for (win.items) |entry| { 700 // conflicts take precedence over the comment count 701 const label: []const u8 = if (entryConflicted(entry) and status_maybe != null) 702 " conflict " 703 else if (entry.comments.count > 0) 704 try std.fmt.allocPrint(session.page_arena.allocator(), " {d} comments ", .{entry.comments.count}) 705 else 706 ""; 707 try addRow(allocator, &list_box, entry.record.event.title, label, try rowLink(session.page_arena, data, entry.id)); 708 } 709 if (win.next_id) |next| 710 try addRow(allocator, &list_box, "next →", "", try windowLink(session.page_arena, data, status_maybe, drafts, next)); 711 // select the window's first thread (past a leading "previous" 712 // row) so its description shows on load. 713 if (win.items.len > 0) 714 list_box.getFocus().child_id = list_box.children.keys()[if (win.prev_id != null) 1 else 0] 715 else if (list_box.children.count() > 0) 716 list_box.getFocus().child_id = list_box.children.keys()[0]; 717 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = list_box }, .{ .direction = .vert, .web_native = !session.is_terminal }); 718 }; 719 errdefer list_scroll.deinit(allocator); 720 try box.children.put(allocator, list_scroll.getFocus().id, .{ .widget = .{ .scroll = list_scroll }, .rect = null, .min_size = .{ .width = list_max_width, .height = null }, .max_size = .{ .width = list_max_width, .height = null } }); 721 } 722 723 // the detail pane — a frame around a scroll of the description 724 { 725 var detail_outer = blk: { 726 var detail_scroll = blk2: { 727 var detail_inner = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 728 errdefer detail_inner.deinit(allocator); 729 // fill the pane (content top-left, scroll bar pinned to the 730 // edge) rather than shrinking to the description. 731 break :blk2 try wgt.Scroll(ui.Widget).init(allocator, .{ .box = detail_inner }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 732 }; 733 errdefer detail_scroll.deinit(allocator); 734 var frame = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = .hidden, .direction = .vert }); 735 errdefer frame.deinit(allocator); 736 // the tool row sits above the scroll (populateDetail fills 737 // it per thread) so it can't scroll out from under the web 738 // overlay <form>, whose position doesn't track pane scrolling. 739 { 740 var row = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 741 errdefer row.deinit(allocator); 742 try frame.children.put(allocator, row.getFocus().id, .{ .widget = .{ .box = row }, .rect = null, .min_size = null }); 743 } 744 // the frame's selected child is its scroll, so the focus chain 745 // reaches the description (populateDetail points the scroll's 746 // inner box at it), letting focus recovery descend into the pane. 747 frame.getFocus().child_id = detail_scroll.getFocus().id; 748 try frame.children.put(allocator, detail_scroll.getFocus().id, .{ .widget = .{ .scroll = detail_scroll }, .rect = null, .min_size = null }); 749 break :blk frame; 750 }; 751 errdefer detail_outer.deinit(allocator); 752 try box.children.put(allocator, detail_outer.getFocus().id, .{ .widget = .{ .box = detail_outer }, .rect = null, .min_size = .{ .width = detail_min_width, .height = null } }); 753 } 754 755 box.getFocus().child_id = box.children.keys()[list_index]; 756 return box; 757 } 758 759 // a thread form: title/tags/description inputs and a submit button, 760 // prefilled from `record` when given. its form: subtree makes the web 761 // overlay wrap them in a <form> POSTing to `action`'s route. 762 fn initThreadForm(allocator: std.mem.Allocator, session: *ui.Session, action: []const u8, record: ?*const Event.Record) !wgt.Box(ui.Widget) { 763 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 764 errdefer box.deinit(allocator); 765 box.getFocus().kind = .{ .custom = action }; 766 767 { 768 var title = try wgt.TextInput(ui.Widget).init(allocator, .{ .label = " title ", .name = "title", .visible_width = null, .rounded_corners = true, .render_content = session.is_terminal }); 769 errdefer title.deinit(allocator); 770 title.getFocus().focusable = true; 771 if (record) |r| try title.setContent(allocator, r.event.title); 772 try box.children.put(allocator, title.getFocus().id, .{ .widget = .{ .text_input = title }, .rect = null, .min_size = .{ .width = null, .height = 3 } }); 773 } 774 775 { 776 var tags = try wgt.TextInput(ui.Widget).init(allocator, .{ .label = " tags (separate with spaces) ", .name = "tags", .visible_width = null, .rounded_corners = true, .render_content = session.is_terminal }); 777 errdefer tags.deinit(allocator); 778 tags.getFocus().focusable = true; 779 if (record) |r| try tags.setContent(allocator, r.event.tags); 780 try box.children.put(allocator, tags.getFocus().id, .{ .widget = .{ .text_input = tags }, .rect = null, .min_size = .{ .width = null, .height = 3 } }); 781 } 782 783 { 784 var description = try wgt.TextInput(ui.Widget).init(allocator, .{ .label = " description ", .name = "description", .visible_width = null, .rounded_corners = true, .render_content = session.is_terminal, .multiline = true, .scroll = .{ .fill = true } }); 785 errdefer description.deinit(allocator); 786 description.getFocus().focusable = true; 787 if (record) |r| try description.setContent(allocator, r.event.description); 788 try box.children.put(allocator, description.getFocus().id, .{ .widget = .{ .text_input = description }, .rect = null, .min_size = null }); 789 } 790 791 try addSubmitButtonLabeled(allocator, &box, if (kind == .patch and record == null) "submit draft" else "submit"); 792 793 box.getFocus().child_id = box.children.keys()[title_field_index]; 794 return box; 795 } 796 797 fn initCommentForm(allocator: std.mem.Allocator, session: *ui.Session, action: []const u8, author: ui.Author, parent_route: ui.RoutablePage, initial_body: ?[]const u8) !wgt.Box(ui.Widget) { 798 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 799 errdefer box.deinit(allocator); 800 box.getFocus().kind = .{ .custom = action }; 801 802 { 803 var parent = try ui.authorBox(allocator, session.page_arena, author); 804 errdefer parent.deinit(allocator); 805 parent.options.label = " replying to "; 806 parent.getFocus().kind = .{ .custom = try std.fmt.allocPrint(session.page_arena.allocator(), "a:{s}", .{try parent_route.toUrl(session.page_arena)}) }; 807 try box.children.put(allocator, parent.getFocus().id, .{ .widget = .{ .text_box = parent }, .rect = null, .min_size = .{ .width = null, .height = 3 } }); 808 } 809 810 { 811 var body = try wgt.TextInput(ui.Widget).init(allocator, .{ 812 .label = " comment ", 813 .name = "body", 814 .visible_width = null, 815 .rounded_corners = true, 816 .render_content = session.is_terminal, 817 .multiline = true, 818 .visible_height = 5, 819 .scroll = .{ .fill = true }, 820 }); 821 errdefer body.deinit(allocator); 822 body.getFocus().focusable = true; 823 if (initial_body) |text| try body.setContent(allocator, text); 824 try box.children.put(allocator, body.getFocus().id, .{ .widget = .{ .text_input = body }, .rect = null, .min_size = null }); 825 } 826 827 try addSubmitButtonLabeled(allocator, &box, "submit"); 828 box.getFocus().child_id = box.children.keys()[comment_author_field_index]; 829 return box; 830 } 831 832 fn initRemoveForm(allocator: std.mem.Allocator, action: []const u8, label: []const u8) !ui.Center { 833 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .rounded_corners = true, .direction = .vert }); 834 errdefer box.deinit(allocator); 835 box.getFocus().kind = .{ .custom = action }; 836 837 var prompt = try wgt.Text(ui.Widget).init(allocator, "are you sure?"); 838 errdefer prompt.deinit(allocator); 839 try box.children.put(allocator, prompt.getFocus().id, .{ .widget = .{ .text = prompt }, .rect = null, .min_size = null }); 840 841 var button = try wgt.TextBox(ui.Widget).init(allocator, label, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); 842 errdefer button.deinit(allocator); 843 button.getFocus().focusable = true; 844 button.getFocus().kind = .{ .custom = "submit" }; 845 try box.children.put(allocator, button.getFocus().id, .{ .widget = .{ .text_box = button }, .rect = null, .min_size = null }); 846 box.getFocus().child_id = button.getFocus().id; 847 848 return ui.Center.init(allocator, .{ .box = box }); 849 } 850 851 // a form's submit button, then a spacer absorbing the leftover 852 // min-height the box hands its last child, so the button keeps its 853 // natural height 854 fn addSubmitButtonLabeled(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), label: []const u8) !void { 855 { 856 var submit = try ui.SubmitButton.initLabeled(allocator, label); 857 errdefer submit.deinit(allocator); 858 try box.children.put(allocator, submit.getFocus().id, .{ .widget = .{ .submit_button = submit }, .rect = null, .min_size = .{ .width = null, .height = 3 } }); 859 } 860 { 861 var spacer = try ui.Spacer.init(allocator); 862 errdefer spacer.deinit(allocator); 863 try box.children.put(allocator, spacer.getFocus().id, .{ .widget = .{ .spacer = spacer }, .rect = null, .min_size = null }); 864 } 865 } 866 867 // the conflict resolve form. the "use this" links are navigations that 868 // flip the url's theirs: pick, reloading the prefills; one submit 869 // settles every conflict. the form: subtree makes the web overlay POST 870 // to the resolve route. 871 fn initResolveForm(allocator: std.mem.Allocator, session: *ui.Session, data: *const Self) !wgt.Box(ui.Widget) { 872 const aa = session.page_arena.allocator(); 873 // init only picks the resolve view once it has read the conflict 874 const conflict = if (data.conflict) |*c| c else unreachable; 875 876 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 877 errdefer box.deinit(allocator); 878 const route = resolveRoute(data.identity, data.selected_id, data.theirs_picks) orelse return error.RouteTooLong; 879 box.getFocus().kind = .{ .custom = try std.fmt.allocPrint(aa, "form:{s}", .{try route.toUrl(session.page_arena)}) }; 880 881 if (conflict.title) |*fc| { 882 try addLabel(allocator, &box, "title conflict:"); 883 try addFieldConflict(allocator, &box, session, data, "title", fc); 884 } 885 if (conflict.tags) |*fc| { 886 try addLabel(allocator, &box, "tags conflict:"); 887 try addFieldConflict(allocator, &box, session, data, "tags", fc); 888 } 889 if (comptime kind == .patch) { 890 if (conflict.status) |*fc| { 891 try addLabel(allocator, &box, "status conflict:"); 892 try addAtomicConflict(allocator, &box, session, data, "status", fc); 893 } 894 if (conflict.revision) |*fc| { 895 try addLabel(allocator, &box, "revision conflict:"); 896 try addAtomicConflict(allocator, &box, session, data, "revision", fc); 897 } 898 } 899 900 if (conflict.description) |*desc| { 901 try addLabel(allocator, &box, "description conflict:"); 902 var hunk_index: usize = 0; 903 for (desc.chunks, 0..) |*chunk, chunk_index| { 904 switch (chunk.*) { 905 .same => |text| { 906 var tb = try wgt.TextBox(ui.Widget).init(allocator, text, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .word }); 907 errdefer tb.deinit(allocator); 908 tb.getFocus().focusable = true; 909 try box.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); 910 }, 911 // an auto-resolved chunk: only one side changed it, so its 912 // version stands, shown distinctly with its author 913 .auto => |auto| { 914 const author = if (auto.theirs) desc.theirs_author else desc.ours_author; 915 const verb: []const u8 = if (auto.text == null) "removed" else "edited"; 916 const label = switch (author) { 917 .user_name, .email => |name| try std.fmt.allocPrint(aa, " {s} by {s} ", .{ verb, name }), 918 .unknown => try std.fmt.allocPrint(aa, " {s} by {s} ", .{ verb, if (auto.theirs) @as([]const u8, "them") else "us" }), 919 }; 920 var tb = try wgt.TextBox(ui.Widget).init(allocator, auto.text orelse "(removed)", .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .word, .label = label }); 921 errdefer tb.deinit(allocator); 922 tb.getFocus().focusable = true; 923 try box.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); 924 }, 925 .conflict => |hunk| { 926 // a blank row on each side sets the group apart from 927 // the flowing shared and auto-resolved chunks 928 if (chunk_index > 0) try addGap(allocator, &box); 929 const name = try std.fmt.allocPrint(aa, "d{d}", .{hunk_index}); 930 hunk_index += 1; 931 const picked_theirs = data.theirsPicked(name); 932 try addVersionRow(allocator, &box, try sideLabel(aa, desc.ours_author, true), hunk.ours orelse "", try useThisLink(session, data, name, false)); 933 try addVersionRow(allocator, &box, try sideLabel(aa, desc.theirs_author, false), hunk.theirs orelse "", try useThisLink(session, data, name, true)); 934 var resolution_input = try wgt.TextInput(ui.Widget).init(allocator, .{ .label = " resolution ", .name = name, .visible_width = null, .rounded_corners = true, .render_content = session.is_terminal, .multiline = true }); 935 errdefer resolution_input.deinit(allocator); 936 resolution_input.getFocus().focusable = true; 937 try resolution_input.setContent(allocator, (if (picked_theirs) hunk.theirs else hunk.ours) orelse ""); 938 try box.children.put(allocator, resolution_input.getFocus().id, .{ .widget = .{ .text_input = resolution_input }, .rect = null, .min_size = null }); 939 if (chunk_index + 1 < desc.chunks.len) try addGap(allocator, &box); 940 }, 941 } 942 } 943 } 944 945 try addSubmitButtonLabeled(allocator, &box, "submit"); 946 947 box.getFocus().child_id = box.children.keys()[0]; 948 return box; 949 } 950 951 // one conflicted scalar field: both sides' version rows, then the 952 // resolution input prefilled from the picked side. 953 fn addFieldConflict(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), session: *ui.Session, data: *const Self, comptime name: []const u8, fc: *const FieldConflict) !void { 954 const aa = session.page_arena.allocator(); 955 try addVersionRow(allocator, box, try sideLabel(aa, fc.ours.author, true), fc.ours.text, try useThisLink(session, data, name, false)); 956 try addVersionRow(allocator, box, try sideLabel(aa, fc.theirs.author, false), fc.theirs.text, try useThisLink(session, data, name, true)); 957 958 var resolution_input = try wgt.TextInput(ui.Widget).init(allocator, .{ .label = " resolution ", .name = name, .visible_width = null, .rounded_corners = true, .render_content = session.is_terminal }); 959 errdefer resolution_input.deinit(allocator); 960 resolution_input.getFocus().focusable = true; 961 try resolution_input.setContent(allocator, if (data.theirsPicked(name)) fc.theirs.text else fc.ours.text); 962 try box.children.put(allocator, resolution_input.getFocus().id, .{ .widget = .{ .text_input = resolution_input }, .rect = null, .min_size = null }); 963 } 964 965 // atomic fields can only pick one complete side 966 fn addAtomicConflict(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), session: *ui.Session, data: *const Self, comptime name: []const u8, fc: *const FieldConflict) !void { 967 const aa = session.page_arena.allocator(); 968 try addVersionRow(allocator, box, try sideLabel(aa, fc.ours.author, true), fc.ours.text, try useThisLink(session, data, name, false)); 969 try addVersionRow(allocator, box, try sideLabel(aa, fc.theirs.author, false), fc.theirs.text, try useThisLink(session, data, name, true)); 970 var selected = try wgt.TextBox(ui.Widget).init(allocator, if (data.theirsPicked(name)) fc.theirs.text else fc.ours.text, .{ 971 .border_style = .single, 972 .rounded_corners = true, 973 .wrap_kind = .word, 974 .label = " resolution ", 975 }); 976 errdefer selected.deinit(allocator); 977 selected.getFocus().focusable = true; 978 try box.children.put(allocator, selected.getFocus().id, .{ .widget = .{ .text_box = selected }, .rect = null, .min_size = null }); 979 } 980 981 // a blank row setting a conflict group apart from its neighbors 982 fn addGap(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget)) !void { 983 var text = try wgt.Text(ui.Widget).init(allocator, " "); 984 errdefer text.deinit(allocator); 985 try box.children.put(allocator, text.getFocus().id, .{ .widget = .{ .text = text }, .rect = null, .min_size = null }); 986 } 987 988 fn addLabel(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), content: []const u8) !void { 989 var label = try ui.SectionLabel.init(allocator, content); 990 errdefer label.deinit(allocator); 991 try box.children.put(allocator, label.getFocus().id, .{ .widget = .{ .section_label = label }, .rect = null, .min_size = null }); 992 } 993 994 // one side of a conflict: the "use this" link on the left, the version 995 // itself as a labeled read-only box. 996 fn addVersionRow(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), label: []const u8, text: []const u8, link: []const u8) !void { 997 var row = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 998 errdefer row.deinit(allocator); 999 1000 { 1001 const use_label = "use this"; 1002 var use = try wgt.TextBox(ui.Widget).init(allocator, use_label, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); 1003 errdefer use.deinit(allocator); 1004 use.getFocus().focusable = true; 1005 use.getFocus().kind = .{ .custom = link }; 1006 try row.children.put(allocator, use.getFocus().id, .{ 1007 .widget = .{ .text_box = use }, 1008 .rect = null, 1009 .min_size = .{ .width = use_label.len + 2, .height = null }, 1010 .max_size = .{ .width = use_label.len + 2, .height = null }, 1011 }); 1012 } 1013 1014 { 1015 var tb = try wgt.TextBox(ui.Widget).init(allocator, if (text.len > 0) text else "(removed)", .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .word, .label = label }); 1016 errdefer tb.deinit(allocator); 1017 tb.getFocus().focusable = true; 1018 try row.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); 1019 } 1020 1021 row.getFocus().child_id = row.children.keys()[0]; 1022 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .box = row }, .rect = null, .min_size = null }); 1023 } 1024 1025 // the label of one side's version box, naming its author 1026 fn sideLabel(aa: std.mem.Allocator, author: ui.Author, ours: bool) ![]const u8 { 1027 return switch (author) { 1028 .user_name, .email => |name| try std.fmt.allocPrint(aa, " edited by {s} ", .{name}), 1029 .unknown => if (ours) " current version " else " their version ", 1030 }; 1031 } 1032 1033 // the a: link that reloads the resolve page with `name` picked from ours 1034 // or theirs, reprefilling its resolution input 1035 fn useThisLink(session: *ui.Session, data: *const Self, name: []const u8, theirs: bool) ![]const u8 { 1036 const aa = session.page_arena.allocator(); 1037 var picks: std.ArrayList(u8) = .empty; 1038 var it = std.mem.splitScalar(u8, data.theirs_picks, ','); 1039 while (it.next()) |pick| { 1040 if (pick.len == 0 or std.mem.eql(u8, pick, name)) continue; 1041 if (picks.items.len > 0) try picks.append(aa, ','); 1042 try picks.appendSlice(aa, pick); 1043 } 1044 if (theirs) { 1045 if (picks.items.len > 0) try picks.append(aa, ','); 1046 try picks.appendSlice(aa, name); 1047 } 1048 const route = resolveRoute(data.identity, data.selected_id, picks.items) orelse return error.RouteTooLong; 1049 return std.fmt.allocPrint(aa, "a:{s}", .{try route.toUrl(session.page_arena)}); 1050 } 1051 1052 // the resolve form's page-constant initial value for the input `name`: 1053 // the picked side of its conflicted field or hunk 1054 fn resolvePrefill(self: *This, name: []const u8) ?[]const u8 { 1055 const conflict = if (self.data.conflict) |*c| c else return null; 1056 const theirs = self.data.theirsPicked(name); 1057 if (std.mem.eql(u8, name, "title")) { 1058 const fc = if (conflict.title) |*f| f else return null; 1059 return if (theirs) fc.theirs.text else fc.ours.text; 1060 } 1061 if (std.mem.eql(u8, name, "tags")) { 1062 const fc = if (conflict.tags) |*f| f else return null; 1063 return if (theirs) fc.theirs.text else fc.ours.text; 1064 } 1065 if (std.mem.startsWith(u8, name, "d")) { 1066 const desc = if (conflict.description) |*d| d else return null; 1067 const hunk_index = std.fmt.parseInt(usize, name[1..], 10) catch return null; 1068 var seen: usize = 0; 1069 for (desc.chunks) |chunk| switch (chunk) { 1070 .conflict => |hunk| { 1071 if (seen == hunk_index) return (if (theirs) hunk.theirs else hunk.ours) orelse ""; 1072 seen += 1; 1073 }, 1074 else => {}, 1075 }; 1076 } 1077 return null; 1078 } 1079 1080 fn addRow(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), text: []const u8, bottom_label: []const u8, link: []const u8) !void { 1081 var row = try wgt.TextBox(ui.Widget).init(allocator, text, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .word, .bottom_label = bottom_label }); 1082 errdefer row.deinit(allocator); 1083 row.getFocus().focusable = true; 1084 if (link.len != 0) row.getFocus().kind = .{ .custom = link }; 1085 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .text_box = row }, .rect = null, .min_size = null, .max_size = .{ .width = null, .height = 5 } }); 1086 } 1087 1088 fn addToolButton(allocator: std.mem.Allocator, row: *wgt.Box(ui.Widget), label: []const u8, action: []const u8) !void { 1089 var button = try wgt.TextBox(ui.Widget).init(allocator, label, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); 1090 errdefer button.deinit(allocator); 1091 button.getFocus().focusable = true; 1092 button.getFocus().kind = .{ .custom = action }; 1093 try row.children.put(allocator, button.getFocus().id, .{ .widget = .{ .text_box = button }, .rect = null, .min_size = .{ .width = try xitui.width.displayWidth(label) + 2, .height = null } }); 1094 } 1095 1096 fn addDetailGap(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget)) !void { 1097 var spacer = try ui.Spacer.init(allocator); 1098 errdefer spacer.deinit(allocator); 1099 try box.children.put(allocator, spacer.getFocus().id, .{ .widget = .{ .spacer = spacer }, .rect = null, .min_size = .{ .width = null, .height = 1 }, .max_size = .{ .width = null, .height = 1 } }); 1100 } 1101 1102 pub fn deinit(self: *This, allocator: std.mem.Allocator) void { 1103 self.box.deinit(allocator); 1104 } 1105 1106 fn header(self: *This) *HeaderType { 1107 return switch (kind) { 1108 .issue => &self.box.children.values()[header_index].widget.repo_issues_header, 1109 .patch => &self.box.children.values()[header_index].widget.repo_patches_header, 1110 .discuss => &self.box.children.values()[header_index].widget.repo_discussions_header, 1111 else => @compileError("unsupported thread event kind"), 1112 }; 1113 } 1114 1115 fn viewStack(self: *This) *wgt.Stack(ui.Widget) { 1116 return &self.box.children.values()[stack_index].widget.stack; 1117 } 1118 1119 // `index`'s master-detail split inside the stack. 1120 fn resultsBox(self: *This, index: usize) *wgt.Box(ui.Widget) { 1121 return &self.viewStack().children.values()[index].box; 1122 } 1123 1124 // the tags view's flow inside the stack. 1125 fn tagsView(self: *This) *ui.TagFlow { 1126 return &self.viewStack().children.values()[tags_view_index].tag_flow; 1127 } 1128 1129 // the new-thread, edit, or resolve form inside the stack, or null when the 1130 // unauthorized view stands in for it. 1131 fn threadForm(self: *This) ?*wgt.Box(ui.Widget) { 1132 return switch (self.viewStack().children.values()[form_view_index]) { 1133 .box => |*box| box, 1134 // the resolve form sits inside a scroll on the terminal 1135 .scroll => |*scroll| &scroll.child.box, 1136 else => null, 1137 }; 1138 } 1139 1140 fn removeForm(self: *This) ?*wgt.Box(ui.Widget) { 1141 return switch (self.viewStack().children.values()[form_view_index]) { 1142 .center => |*center| switch (center.child.*) { 1143 .box => |*box| box, 1144 else => null, 1145 }, 1146 else => null, 1147 }; 1148 } 1149 1150 // the resolve form's scroll on the terminal (the web page scrolls itself) 1151 fn resolveScroll(self: *This) ?*wgt.Scroll(ui.Widget) { 1152 return switch (self.viewStack().children.values()[form_view_index]) { 1153 .scroll => |*scroll| scroll, 1154 else => null, 1155 }; 1156 } 1157 1158 fn listScroll(self: *This, index: usize) *wgt.Scroll(ui.Widget) { 1159 return &self.resultsBox(index).children.values()[list_index].widget.scroll; 1160 } 1161 1162 fn listBox(self: *This, index: usize) *wgt.Box(ui.Widget) { 1163 return &self.listScroll(index).child.box; 1164 } 1165 1166 // the detail frame's children: the tool row above the scroll pane. 1167 const tool_row_index: usize = 0; 1168 const detail_scroll_index: usize = 1; 1169 1170 fn detailOuter(self: *This, index: usize) *wgt.Box(ui.Widget) { 1171 return &self.resultsBox(index).children.values()[detail_index].widget.box; 1172 } 1173 1174 fn toolRow(self: *This, index: usize) *wgt.Box(ui.Widget) { 1175 return &self.detailOuter(index).children.values()[tool_row_index].widget.box; 1176 } 1177 1178 fn detailScroll(self: *This, index: usize) *wgt.Scroll(ui.Widget) { 1179 return &self.detailOuter(index).children.values()[detail_scroll_index].widget.scroll; 1180 } 1181 1182 fn detailInner(self: *This, index: usize) *wgt.Box(ui.Widget) { 1183 return &self.detailScroll(index).child.box; 1184 } 1185 1186 fn window(self: *This, index: usize) *const Window { 1187 if (has_conflicts and index == conflict_view_index) return &self.data.conflicts; 1188 if (has_drafts and index == drafts_view_index) return &self.data.drafts; 1189 return self.data.window(splitStatus(index)); 1190 } 1191 1192 fn stackSelectedIndex(self: *This) ?usize { 1193 const stack = self.viewStack(); 1194 const cid = stack.getFocus().child_id orelse return null; 1195 return stack.children.getIndex(cid); 1196 } 1197 1198 // the stack's selected master-detail split (null when the tags view or the 1199 // new-thread form shows). 1200 fn selectedSplitIndex(self: *This) ?usize { 1201 const idx = self.stackSelectedIndex() orelse return null; 1202 return if (idx < status_count or (has_drafts and idx == drafts_view_index) or idx == conflict_view_index) idx else null; 1203 } 1204 1205 fn detailActive(self: *This, index: usize) bool { 1206 const rb = self.resultsBox(index); 1207 const cid = rb.getFocus().child_id orelse return false; 1208 return rb.children.getIndex(cid) == detail_index; 1209 } 1210 1211 fn headerActive(self: *This) bool { 1212 const cid = self.box.getFocus().child_id orelse return false; 1213 return self.box.children.getIndex(cid) == header_index; 1214 } 1215 1216 fn tagsViewActive(self: *This) bool { 1217 if (self.headerActive()) return false; 1218 return self.stackSelectedIndex() == tags_view_index; 1219 } 1220 1221 fn formViewActive(self: *This) bool { 1222 if (self.headerActive()) return false; 1223 return self.stackSelectedIndex() == form_view_index; 1224 } 1225 1226 // the selected thread's index, or null when a window-navigation row is 1227 // selected (a leading "previous" row shifts the thread rows down by one). 1228 fn selectedThreadIndex(self: *This, index: usize) ?usize { 1229 const lb = self.listBox(index); 1230 const cid = lb.getFocus().child_id orelse return null; 1231 const idx = lb.children.getIndex(cid) orelse return null; 1232 const win = self.window(index); 1233 const lead: usize = if (win.prev_id != null) 1 else 0; 1234 if (idx < lead or idx - lead >= win.items.len) return null; 1235 return idx - lead; 1236 } 1237 1238 pub fn build(self: *This, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 1239 self.clearGrid(); 1240 1241 // each header tab maps 1:1 to a stack child by position 1242 if (self.header().getSelectedIndex()) |index| { 1243 const stack = self.viewStack(); 1244 stack.getFocus().child_id = stack.children.keys()[index]; 1245 } 1246 1247 if (self.selectedSplitIndex()) |i| { 1248 // swap the detail pane to the selected thread when it changes. 1249 if (self.selectedThreadIndex(i)) |selected| { 1250 const changed = if (self.detailed_index[i]) |current| current != selected else true; 1251 if (changed) { 1252 try self.populateDetail(allocator, i, selected); 1253 self.detailed_index[i] = selected; 1254 } 1255 } 1256 1257 // the selected list row shows a border (the focused TextBox 1258 // upgrades it to a double border itself); the rest stay borderless. 1259 const lb = self.listBox(i); 1260 for (lb.children.keys(), lb.children.values()) |id, *child| { 1261 switch (child.widget) { 1262 .text_box => |*tb| tb.options.border_style = if (lb.getFocus().child_id == id) .single else .hidden, 1263 else => {}, 1264 } 1265 } 1266 1267 // the pane's selected child shows the selection border: the 1268 // description directly, the tags via the flow's selected item. 1269 // cap the list at list_max_width only while the detail pane fits 1270 // beside it. the box drops the detail when the width can't hold 1271 // both minimums, so when it's that narrow we lift the cap and let 1272 // the list fill the whole width. 1273 const both_panes_fit = if (constraint.max_size.width) |w| w >= list_max_width + detail_min_width else true; 1274 self.resultsBox(i).children.values()[list_index].max_size = if (both_panes_fit) .{ .width = list_max_width, .height = null } else null; 1275 1276 // stretch the detail pane across the rest of the width so it fills 1277 // the area rather than shrinking to its content; its scroll fills 1278 // the pane. 1279 if (constraint.max_size.width) |w| { 1280 self.resultsBox(i).children.values()[detail_index].min_size = .{ .width = if (both_panes_fit) w - list_max_width else w, .height = null }; 1281 } else { 1282 self.resultsBox(i).children.values()[detail_index].min_size = .{ .width = detail_min_width, .height = null }; 1283 } 1284 } 1285 1286 // refresh the form inputs' entries in the session's focus-id -> input 1287 // map with this frame's addresses, so the web/wasm form handling can 1288 // find them by focus id 1289 if (self.threadForm()) |form| { 1290 const inputs_arena = self.session.arena.allocator(); 1291 for (form.children.values()) |*child| switch (child.widget) { 1292 .text_input => |*ti| try self.session.text_inputs.put(inputs_arena, ti.getFocus().id, ti), 1293 else => {}, 1294 }; 1295 1296 // on an edit page, register the thread's content as the inputs' 1297 // page-constant initial values, which the web overlay renders 1298 // into them. 1299 if (self.data.view == .edit) { 1300 if (self.data.selectedThread()) |entry| { 1301 const fields = form.children.values(); 1302 try self.session.input_values.put(inputs_arena, fields[title_field_index].widget.text_input.getFocus().id, entry.record.event.title); 1303 try self.session.input_values.put(inputs_arena, fields[tags_field_index].widget.text_input.getFocus().id, entry.record.event.tags); 1304 try self.session.input_values.put(inputs_arena, fields[description_field_index].widget.text_input.getFocus().id, entry.record.event.description); 1305 } 1306 } 1307 1308 if (self.data.view == .edit_comment) { 1309 if (self.data.comment_page) |page| { 1310 const body = &form.children.values()[comment_body_field_index].widget.text_input; 1311 try self.session.input_values.put(inputs_arena, body.getFocus().id, page.selected.comment.event.body); 1312 } 1313 } 1314 1315 // the resolve form's inputs prefill from the picked side, which 1316 // is url state and so page-constant like the edit values above 1317 if (has_conflicts and self.data.view == .resolve) { 1318 for (form.children.values()) |*child| switch (child.widget) { 1319 .text_input => |*ti| if (self.resolvePrefill(ti.options.name)) |value| { 1320 try self.session.input_values.put(inputs_arena, ti.getFocus().id, value); 1321 }, 1322 else => {}, 1323 }; 1324 } 1325 } 1326 1327 try self.box.build(allocator, constraint, root_focus); 1328 } 1329 1330 fn populateDetail(self: *This, allocator: std.mem.Allocator, index: usize, sel: usize) !void { 1331 const entry = self.window(index).items[sel]; 1332 const inner = self.detailInner(index); 1333 // the /description page replaces its thread's detail with a back link 1334 // and the whole description; other threads keep their normal detail. 1335 const description_page = self.data.description_page and std.mem.eql(u8, entry.id, self.data.selected_id); 1336 const comment_page = if (std.mem.eql(u8, entry.id, self.data.selected_id)) self.data.comment_page else null; 1337 1338 for (inner.children.values()) |*child| child.widget.deinit(allocator); 1339 inner.children.clearAndFree(allocator); 1340 inner.getFocus().child_id = null; 1341 self.title_id[index] = null; 1342 self.author_id[index] = null; 1343 1344 // the tool row; the description page shows none. 1345 { 1346 const row = self.toolRow(index); 1347 for (row.children.values()) |*child| child.widget.deinit(allocator); 1348 row.children.clearAndFree(allocator); 1349 row.getFocus().child_id = null; 1350 row.getFocus().kind = .container; 1351 } 1352 if (!description_page and comment_page == null) { 1353 const row = self.toolRow(index); 1354 const pa = self.session.page_arena.allocator(); 1355 1356 { 1357 var spacer = try ui.Spacer.init(allocator); 1358 errdefer spacer.deinit(allocator); 1359 try row.children.put(allocator, spacer.getFocus().id, .{ .widget = .{ .spacer = spacer }, .rect = null, .min_size = null }); 1360 } 1361 1362 if (has_drafts and entryDraft(entry)) { 1363 if (entry.revision_ready) { 1364 const route = listRoute(self.data.identity, @enumFromInt(0), "", entry.id) orelse return error.RouteTooLong; 1365 row.getFocus().kind = .{ .custom = try std.fmt.allocPrint(pa, "form:{s}/post", .{try route.toUrl(self.session.page_arena)}) }; 1366 try addToolButton(allocator, row, "post", "submit"); 1367 } 1368 if (row.children.count() > first_in_row_index) 1369 row.getFocus().child_id = row.children.keys()[first_in_row_index]; 1370 } else { 1371 // a terminal has no file picker, so attaching is web only. the 1372 // renderer covers this button with a file input, so clicking it 1373 // opens the browser's own picker and posts what it chose. 1374 if (!self.session.is_terminal and !entryConflicted(entry) and (self.session.data.is_local or self.session.data.user_id != null)) { 1375 const label = "add attachment"; 1376 const action = if (self.data.identity.len == 0) 1377 try std.fmt.allocPrint(pa, "{s}/{s}:{s}/attach", .{ ui.file_input_prefix, @tagName(kind), entry.id }) 1378 else 1379 try std.fmt.allocPrint(pa, "{s}/repo/{s}/{s}:{s}/attach", .{ ui.file_input_prefix, self.data.identity, @tagName(kind), entry.id }); 1380 try addToolButton(allocator, row, label, action); 1381 } 1382 1383 // a conflicted thread's only action is resolving it. the button 1384 // stays visible logged out; the resolve page shows the 1385 // unauthorized view then. 1386 if (has_conflicts and entryConflicted(entry)) { 1387 const label = "resolve conflict"; 1388 const route = resolveRoute(self.data.identity, entry.id, "") orelse return error.RouteTooLong; 1389 try addToolButton(allocator, row, label, try std.fmt.allocPrint(pa, "a:{s}", .{try route.toUrl(self.session.page_arena)})); 1390 } else { 1391 // a logged-out session can't flip the status, so it gets no 1392 // open/close button 1393 if (has_status and (self.session.data.is_local or self.session.data.user_id != null)) { 1394 if (statusChange(entryStatus(entry))) |change| { 1395 const route = ui.RoutablePage.repoThreadCommentsRoute(kind, self.data.identity, entry.id, 0) orelse return error.RouteTooLong; 1396 row.getFocus().kind = .{ .custom = try std.fmt.allocPrint(pa, "form:{s}/{s}", .{ try route.toUrl(self.session.page_arena), change.action }) }; 1397 try addToolButton(allocator, row, change.action, "submit"); 1398 } 1399 } 1400 1401 // the edit button links to the thread's edit page. 1402 { 1403 const route = ui.RoutablePage.repoThreadEditRoute(kind, self.data.identity, entry.id) orelse return error.RouteTooLong; 1404 try addToolButton(allocator, row, "edit", try std.fmt.allocPrint(pa, "a:{s}", .{try route.toUrl(self.session.page_arena)})); 1405 } 1406 } 1407 1408 if (self.session.data.is_local or self.session.data.user_id != null) { 1409 const route = ui.RoutablePage.repoThreadRemoveRoute(kind, self.data.identity, entry.id, "") orelse return error.RouteTooLong; 1410 try addToolButton(allocator, row, "✕", try std.fmt.allocPrint(pa, "a:{s}", .{try route.toUrl(self.session.page_arena)})); 1411 } 1412 1413 // the leftmost button: the attachment button when the session has 1414 // one, else the resolve button on a conflicted thread, the 1415 // open/close button when present, the edit button otherwise 1416 row.getFocus().child_id = row.children.keys()[first_in_row_index]; 1417 } 1418 } 1419 1420 if (has_drafts and entryDraft(entry) and !description_page and comment_page == null) { 1421 const aa = self.session.page_arena.allocator(); 1422 var fields: [2]ui.CopyableText.Choice = undefined; 1423 var field_count: usize = 0; 1424 1425 if (entry.target_branch.len != 0) if (self.session.data.git_ssh_port) |port| { 1426 const url = try std.fmt.allocPrint(aa, "ssh://localhost:{d}/repo/{s}/patch:{s}/branch:{s}", .{ port, self.data.identity, entry.id, entry.target_branch }); 1427 const push_command = try std.fmt.allocPrint(aa, "git push {s} HEAD:patch", .{url}); 1428 const clone_name = try Data.cloneDirectoryName(aa, entry.record.event.title); 1429 const clone_command = if (clone_name.len == 0) 1430 try std.fmt.allocPrint(aa, "git clone {s}", .{url}) 1431 else 1432 try std.fmt.allocPrint(aa, "git clone {s} {s}", .{ url, clone_name }); 1433 const choices: [2]ui.CopyableText.Choice = .{ 1434 .{ 1435 .selector = "push", 1436 .text = push_command, 1437 .copyable_text = try std.fmt.allocPrint(aa, "{s}{s}", .{ self.session.data.git_ssh_prefix, push_command }), 1438 .label = " push to this patch from existing repo ", 1439 }, 1440 .{ 1441 .selector = "clone", 1442 .text = clone_command, 1443 .copyable_text = try std.fmt.allocPrint(aa, "{s}{s}", .{ self.session.data.git_ssh_prefix, clone_command }), 1444 .label = " clone this patch ", 1445 }, 1446 }; 1447 var copyable_text = try ui.CopyableText.View.init(allocator, self.session, &choices); 1448 errdefer copyable_text.deinit(allocator); 1449 try inner.children.put(allocator, copyable_text.getFocus().id, .{ .widget = .{ .copyable_text = copyable_text }, .rect = null, .min_size = null }); 1450 try addDetailGap(allocator, inner); 1451 }; 1452 if (entry.fork_oid.len != 0) { 1453 fields[field_count] = .{ 1454 .text = entry.fork_oid, 1455 .label = if (entry.no_changes) " object id of this patch (no changes) " else " object id of this patch ", 1456 }; 1457 field_count += 1; 1458 } 1459 if (entry.target_branch.len != 0) { 1460 fields[field_count] = .{ 1461 .text = entry.target_branch, 1462 .label = " target branch this patch will go to ", 1463 .bottom_label = " (set by the url you push to above) ", 1464 }; 1465 field_count += 1; 1466 } 1467 1468 for (fields[0..field_count], 0..) |field, i| { 1469 var copyable_text = try ui.CopyableText.View.init(allocator, self.session, &.{field}); 1470 errdefer copyable_text.deinit(allocator); 1471 try inner.children.put(allocator, copyable_text.getFocus().id, .{ .widget = .{ .copyable_text = copyable_text }, .rect = null, .min_size = null }); 1472 if (i + 1 < field_count) try addDetailGap(allocator, inner); 1473 } 1474 if (entry.target_branch.len != 0) try addDetailGap(allocator, inner); 1475 } 1476 1477 if (comment_page) |page| { 1478 var back_label_buf: ["← back to ".len + thread_name.len]u8 = undefined; 1479 const back_label = try std.fmt.bufPrint(&back_label_buf, "← back to {s}", .{thread_name}); 1480 var back = try Comment.linkBox(allocator, self.session, back_label, ui.RoutablePage.repoThreadCommentsRoute(kind, self.data.identity, entry.id, 0) orelse return error.RouteTooLong); 1481 errdefer back.deinit(allocator); 1482 try inner.children.put(allocator, back.getFocus().id, .{ .widget = .{ .text_box = back }, .rect = null, .min_size = null }); 1483 1484 try Comment.appendComment(allocator, inner, self.session, self.data.identity, kind, page.selected); 1485 try Comment.appendCount(allocator, inner, page.replies.count, "reply", "replies"); 1486 for (page.replies.comments) |comment| try Comment.appendComment(allocator, inner, self.session, self.data.identity, kind, comment); 1487 try Comment.appendWindowNav(allocator, inner, self.session, self.data.identity, kind, &page.selected.comment.event.thread_id, &page.selected.id, page.replies); 1488 1489 var spacer = try ui.Spacer.init(allocator); 1490 errdefer spacer.deinit(allocator); 1491 try inner.children.put(allocator, spacer.getFocus().id, .{ .widget = .{ .spacer = spacer }, .rect = null, .min_size = null }); 1492 1493 inner.getFocus().child_id = inner.children.keys()[0]; 1494 const sc = self.detailScroll(index); 1495 sc.x = 0; 1496 sc.y = 0; 1497 sc.getFocus().version +%= 1; 1498 return; 1499 } 1500 1501 if (description_page) { 1502 // the back link, in the title slot so the pane's input handling 1503 // applies to it unchanged. 1504 var back_label_buf: ["← back to ".len + thread_name.len]u8 = undefined; 1505 const back_label = try std.fmt.bufPrint(&back_label_buf, "← back to {s}", .{thread_name}); 1506 var tb = try wgt.TextBox(ui.Widget).init(allocator, back_label, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); 1507 errdefer tb.deinit(allocator); 1508 tb.getFocus().focusable = true; 1509 tb.getFocus().kind = .{ .custom = try listLink(self.session.page_arena, self.data.identity, entryStatus(entry), "", entry.id) }; 1510 try inner.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); 1511 self.title_id[index] = tb.getFocus().id; 1512 } else { 1513 // the thread's title as a focusable word-wrapped text box. 1514 self.title_id[index] = blk: { 1515 var tb = try wgt.TextBox(ui.Widget).init(allocator, entry.record.event.title, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .word, .label = " title " }); 1516 errdefer tb.deinit(allocator); 1517 tb.getFocus().focusable = true; 1518 try inner.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); 1519 break :blk tb.getFocus().id; 1520 }; 1521 1522 self.author_id[index] = blk: { 1523 var tb = try ui.authorBox(allocator, self.session.page_arena, entry.author); 1524 errdefer tb.deinit(allocator); 1525 try inner.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); 1526 break :blk tb.getFocus().id; 1527 }; 1528 1529 // the thread's tags, each linking to this status's list filtered to 1530 // that tag. 1531 { 1532 var items: std.ArrayList(ui.TagFlow.Item) = .empty; 1533 defer items.deinit(allocator); 1534 var tag_iter = Event.tagIterator(entry.record.event.tags); 1535 while (tag_iter.next()) |tag| { 1536 if (tag.len == 0) continue; 1537 try items.append(allocator, .{ .text = tag, .link = try tagLink(self.session.page_arena, self.data.identity, entryStatus(entry), tag) }); 1538 } 1539 if (items.items.len > 0) { 1540 var tf = try ui.TagFlow.init(allocator); 1541 errdefer tf.deinit(allocator); 1542 try tf.setItems(allocator, items.items); 1543 try inner.children.put(allocator, tf.getFocus().id, .{ .widget = .{ .tag_flow = tf }, .rect = null, .min_size = null }); 1544 } 1545 } 1546 } 1547 1548 // the description as a focusable word-wrapped text box. past the limit 1549 // it's cut at the last whole line and links to the /description page, 1550 // which shows the whole thing. 1551 self.description_id[index] = blk: { 1552 const whole = entry.record.event.description; 1553 const cut_short = !description_page and whole.len > Data.max_description_size; 1554 const shown = if (cut_short) 1555 std.mem.trimEnd(u8, whole[0 .. std.mem.lastIndexOfScalar(u8, whole[0..Data.max_description_size], '\n') orelse 0], " \t\r\n") 1556 else if (whole.len == 0) 1557 "(no description)" 1558 else 1559 whole; 1560 var tb = try wgt.TextBox(ui.Widget).init(allocator, shown, .{ 1561 .border_style = .single, 1562 .rounded_corners = true, 1563 .wrap_kind = .word, 1564 .label = " description ", 1565 .bottom_label = if (cut_short) " click or press enter to see more " else "", 1566 }); 1567 errdefer tb.deinit(allocator); 1568 tb.getFocus().focusable = true; 1569 if (cut_short) { 1570 const route = ui.RoutablePage.repoThreadDescriptionRoute(kind, self.data.identity, entry.id) orelse return error.RouteTooLong; 1571 tb.getFocus().kind = .{ .custom = try std.fmt.allocPrint(self.session.page_arena.allocator(), "a:{s}", .{try route.toUrl(self.session.page_arena)}) }; 1572 } 1573 try inner.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); 1574 break :blk tb.getFocus().id; 1575 }; 1576 1577 if (has_drafts and entryDraft(entry)) { 1578 inner.getFocus().child_id = inner.children.keys()[0]; 1579 const sc = self.detailScroll(index); 1580 sc.x = 0; 1581 sc.y = 0; 1582 sc.getFocus().version +%= 1; 1583 return; 1584 } 1585 1586 const parent_route = ui.RoutablePage.repoThreadCommentsRoute(kind, self.data.identity, entry.id, 0) orelse return error.RouteTooLong; 1587 try Attachment.appendRows(allocator, inner, self.session, self.data.identity, try parent_route.toUrl(self.session.page_arena), entry.attachments); 1588 1589 if (!description_page) { 1590 var reply = try Comment.linkBox(allocator, self.session, "new comment", ui.RoutablePage.repoThreadCommentNewRoute(kind, self.data.identity, entry.id, "") orelse return error.RouteTooLong); 1591 errdefer reply.deinit(allocator); 1592 try inner.children.put(allocator, reply.getFocus().id, .{ .widget = .{ .text_box = reply }, .rect = null, .min_size = null }); 1593 1594 try Comment.appendCount(allocator, inner, entry.comments.count, "comment", "comments"); 1595 for (entry.comments.comments) |comment| try Comment.appendComment(allocator, inner, self.session, self.data.identity, kind, comment); 1596 try Comment.appendWindowNav(allocator, inner, self.session, self.data.identity, kind, entry.id, null, entry.comments); 1597 1598 var spacer = try ui.Spacer.init(allocator); 1599 errdefer spacer.deinit(allocator); 1600 try inner.children.put(allocator, spacer.getFocus().id, .{ .widget = .{ .spacer = spacer }, .rect = null, .min_size = null }); 1601 } 1602 1603 // select the title by default 1604 if (self.title_id[index]) |id| inner.getFocus().child_id = id; 1605 1606 // reset the scroll to the top for the newly-shown thread: directly on the 1607 // terminal (the wasm offset), and via a version bump on the web (so the 1608 // renderer's scroll id changes and JS drops the preserved position). 1609 const sc = self.detailScroll(index); 1610 sc.x = 0; 1611 sc.y = 0; 1612 sc.getFocus().version +%= 1; 1613 } 1614 1615 pub fn input(self: *This, allocator: std.mem.Allocator, raw_key: Key, root_focus: *Focus) !void { 1616 // wheel input moves focus like the arrows 1617 const key: Key = if (raw_key == .mouse and raw_key.mouse.action == .scroll) 1618 (if (raw_key.mouse.action.scroll == .up) .arrow_up else .arrow_down) 1619 else 1620 raw_key; 1621 1622 if (self.headerActive()) { 1623 // down from the tabs re-enters the stack if the selected view has 1624 // something to focus; other keys move the tabs. 1625 if (inp.vertDirection(key) == .down) { 1626 const enterable = if (self.viewStack().getSelected()) |selected| switch (selected.*) { 1627 .tag_flow => |*tf| tf.text_boxes.items.len > 0, 1628 // nothing focusable in the unauthorized view 1629 .unauthorized => false, 1630 else => true, 1631 } else false; 1632 if (enterable) root_focus.setFocus(self.box.children.keys()[stack_index]); 1633 } else { 1634 try self.header().input(allocator, key, root_focus); 1635 } 1636 return; 1637 } 1638 if (self.tagsViewActive()) { 1639 self.tagsViewInput(key, root_focus); 1640 return; 1641 } 1642 if (self.formViewActive()) { 1643 if (self.data.view == .remove) { 1644 try self.removeFormInput(allocator, key, root_focus); 1645 } else if (has_conflicts and self.data.view == .resolve) { 1646 try self.resolveInput(allocator, key, root_focus); 1647 } else if (self.data.view == .new_comment or self.data.view == .edit_comment) { 1648 try self.commentFormInput(allocator, key, root_focus); 1649 } else { 1650 try self.formInput(allocator, key, root_focus); 1651 } 1652 return; 1653 } 1654 const i = self.selectedSplitIndex() orelse return; 1655 if (self.detailActive(i)) { 1656 try self.detailInput(allocator, i, key, root_focus); 1657 } else { 1658 self.listInput(i, key, root_focus); 1659 } 1660 } 1661 1662 // arrow keys move the tag selection; up from the top row crosses to the 1663 // header tabs. 1664 fn tagsViewInput(self: *This, key: Key, root_focus: *Focus) void { 1665 const tf = self.tagsView(); 1666 const cid = tf.focus.child_id orelse return; 1667 const cur = tf.indexOfFocusId(cid) orelse return; 1668 const count = tf.text_boxes.items.len; 1669 switch (key) { 1670 .arrow_left => if (cur > 0) root_focus.setFocus(tf.text_boxes.items[cur - 1].getFocus().id), 1671 .arrow_right => if (cur + 1 < count) root_focus.setFocus(tf.text_boxes.items[cur + 1].getFocus().id), 1672 .arrow_up => if (tf.rowStep(cur, false)) |i| root_focus.setFocus(tf.text_boxes.items[i].getFocus().id) else self.focusHeader(root_focus), 1673 .arrow_down => if (tf.rowStep(cur, true)) |i| root_focus.setFocus(tf.text_boxes.items[i].getFocus().id), 1674 .home => root_focus.setFocus(tf.text_boxes.items[0].getFocus().id), 1675 .end => root_focus.setFocus(tf.text_boxes.items[count - 1].getFocus().id), 1676 else => {}, 1677 } 1678 } 1679 1680 fn listInput(self: *This, index: usize, key: Key, root_focus: *Focus) void { 1681 // up/down (and the scroll wheel) move the selection a row; page up/down 1682 // jump a fixed amount. right/Enter cross into the detail pane. up from 1683 // the top row crosses into the header tabs. 1684 if (inp.rowDelta(key, @intCast(self.listBox(index).children.count()))) |delta| { 1685 const lb = self.listBox(index); 1686 const at_top = if (lb.getFocus().child_id) |cid| lb.children.getIndex(cid) == 0 else true; 1687 if (delta < 0 and at_top) return self.focusHeader(root_focus); 1688 ui.moveRowFocus(lb, self.listScroll(index), root_focus, delta); 1689 return; 1690 } 1691 switch (key) { 1692 .enter, .arrow_right => self.focusDetail(index, root_focus), 1693 else => {}, 1694 } 1695 } 1696 1697 fn detailInput(self: *This, allocator: std.mem.Allocator, index: usize, key: Key, root_focus: *Focus) !void { 1698 switch (key) { 1699 .page_up => { 1700 self.pageDetail(index, root_focus, -10); 1701 return; 1702 }, 1703 .page_down => { 1704 self.pageDetail(index, root_focus, 10); 1705 return; 1706 }, 1707 .home => { 1708 self.jumpDetail(index, root_focus, false); 1709 return; 1710 }, 1711 .end => { 1712 self.jumpDetail(index, root_focus, true); 1713 return; 1714 }, 1715 else => {}, 1716 } 1717 if (self.data.comment_page != null and std.mem.eql(u8, self.window(index).items[self.detailed_index[index] orelse return].id, self.data.selected_id)) { 1718 self.commentInput(index, key, root_focus); 1719 return; 1720 } 1721 if (self.toolRowFocused(index)) { 1722 try self.toolRowInput(allocator, index, key, root_focus); 1723 return; 1724 } 1725 1726 const child_index = self.focusedDetailChild(index, root_focus) orelse return; 1727 const inner = self.detailInner(index); 1728 const child_id = inner.children.keys()[child_index]; 1729 const child = &inner.children.values()[child_index].widget; 1730 1731 if (child_id == self.title_id[index]) { 1732 switch (key) { 1733 .arrow_left => self.focusList(index, root_focus), 1734 .arrow_up => if (!self.moveDetailVertical(index, root_focus, false)) self.focusToolRow(index, root_focus), 1735 .arrow_down => _ = self.moveDetailVertical(index, root_focus, true), 1736 else => {}, 1737 } 1738 return; 1739 } 1740 if (child_id == self.author_id[index]) { 1741 // the author's a: link is followed by the host; arrows cross to 1742 // neighboring widgets. 1743 switch (key) { 1744 .arrow_left => self.focusList(index, root_focus), 1745 .arrow_up => _ = self.moveDetailVertical(index, root_focus, false), 1746 .arrow_down => _ = self.moveDetailVertical(index, root_focus, true), 1747 else => {}, 1748 } 1749 return; 1750 } 1751 if (child_id == self.description_id[index]) { 1752 self.descriptionInput(index, key, root_focus); 1753 return; 1754 } 1755 1756 switch (child.*) { 1757 .tag_flow => |*tf| { 1758 self.tagsInput(index, child_index, tf, key, root_focus); 1759 return; 1760 }, 1761 .copyable_text => |*copyable_text| { 1762 switch (key) { 1763 .arrow_up => if (!self.moveDetailVertical(index, root_focus, false)) self.focusToolRow(index, root_focus), 1764 .arrow_down => _ = self.moveDetailVertical(index, root_focus, true), 1765 .arrow_left => if (copyable_text.selected == 0) 1766 self.focusList(index, root_focus) 1767 else 1768 try copyable_text.input(allocator, key, root_focus), 1769 else => try copyable_text.input(allocator, key, root_focus), 1770 } 1771 return; 1772 }, 1773 .box => |*row| if (Attachment.removeId(row)) |attachment_id| { 1774 const remove_id = row.children.keys()[1]; 1775 switch (key) { 1776 .arrow_left => if (!self.moveCommentHorizontal(index, root_focus, false)) self.focusList(index, root_focus), 1777 .arrow_right => _ = self.moveCommentHorizontal(index, root_focus, true), 1778 .arrow_up => _ = self.moveDetailVertical(index, root_focus, false), 1779 .arrow_down => _ = self.moveDetailVertical(index, root_focus, true), 1780 .enter => if (row.getFocus().child_id == remove_id) try self.removeEvent(allocator, .attach, attachment_id), 1781 .mouse => |mouse| if (inp.leftClickOn(root_focus, remove_id, mouse)) try self.removeEvent(allocator, .attach, attachment_id), 1782 else => {}, 1783 } 1784 return; 1785 }, 1786 else => {}, 1787 } 1788 self.commentInput(index, key, root_focus); 1789 } 1790 1791 // enter or a click runs the primary action; links are handled by the host 1792 fn toolRowInput(self: *This, allocator: std.mem.Allocator, index: usize, key: Key, root_focus: *Focus) !void { 1793 const row = self.toolRow(index); 1794 const cur = if (row.getFocus().child_id) |cid| row.children.getIndex(cid) orelse return else return; 1795 const child_id = row.children.keys()[cur]; 1796 const on_primary = switch (row.children.values()[cur].widget.getFocus().kind) { 1797 .custom => |action| std.mem.eql(u8, action, "submit"), 1798 else => false, 1799 }; 1800 switch (key) { 1801 .arrow_left => if (cur > first_in_row_index) root_focus.setFocus(row.children.keys()[cur - 1]) else self.focusList(index, root_focus), 1802 .arrow_right => if (cur + 1 < row.children.count()) root_focus.setFocus(row.children.keys()[cur + 1]), 1803 .arrow_up => self.focusHeader(root_focus), 1804 .arrow_down => { 1805 self.focusDetailEdge(index, root_focus, false); 1806 self.detailScroll(index).y = 0; 1807 }, 1808 .enter => if (on_primary) try self.primaryAction(allocator, index), 1809 .mouse => |mouse| if (on_primary and inp.leftClickOn(root_focus, child_id, mouse)) try self.primaryAction(allocator, index), 1810 else => {}, 1811 } 1812 } 1813 1814 fn primaryAction(self: *This, allocator: std.mem.Allocator, index: usize) !void { 1815 const selected = self.detailed_index[index] orelse return; 1816 const entry = self.window(index).items[selected]; 1817 if (has_drafts and entryDraft(entry)) 1818 try self.postDraft(allocator, index) 1819 else 1820 try self.toggleStatus(allocator, index); 1821 } 1822 1823 fn postDraft(self: *This, allocator: std.mem.Allocator, index: usize) !void { 1824 if (!has_drafts or comptime wasm) return; 1825 const selected = self.detailed_index[index] orelse return; 1826 const entry = self.window(index).items[selected]; 1827 if (!entryDraft(entry) or !entry.revision_ready) return; 1828 try self.data.postDraft(self.session, allocator, entry.id); 1829 const route = listRoute(self.data.identity, @enumFromInt(0), "", entry.id) orelse return; 1830 try self.session.navigate(route); 1831 } 1832 1833 fn descriptionInput(self: *This, index: usize, key: Key, root_focus: *Focus) void { 1834 const sc = self.detailScroll(index); 1835 switch (key) { 1836 .arrow_left => return self.focusList(index, root_focus), 1837 // the terminal moves through the description one line at a time; 1838 // the web lets focus changes drive its native scroll. 1839 .arrow_up => { 1840 if (!self.session.is_terminal) { 1841 _ = self.moveDetailVertical(index, root_focus, false); 1842 return; 1843 } 1844 if (self.focusAdjacentVisible(index, root_focus, false)) return; 1845 const before = sc.y; 1846 sc.y -= 1; 1847 sc.clampToContent(); 1848 if (sc.y == before) _ = self.moveDetailVertical(index, root_focus, false); 1849 return; 1850 }, 1851 .arrow_down => { 1852 if (!self.session.is_terminal) { 1853 _ = self.moveDetailVertical(index, root_focus, true); 1854 return; 1855 } 1856 if (self.focusAdjacentVisible(index, root_focus, true)) return; 1857 const before = sc.y; 1858 sc.y += 1; 1859 sc.clampToContent(); 1860 if (sc.y == before) _ = self.moveDetailVertical(index, root_focus, true); 1861 return; 1862 }, 1863 else => return, 1864 } 1865 } 1866 1867 fn commentInput(self: *This, index: usize, key: Key, root_focus: *Focus) void { 1868 switch (key) { 1869 .arrow_left => if (!self.moveCommentHorizontal(index, root_focus, false)) return self.focusList(index, root_focus), 1870 .arrow_right => _ = self.moveCommentHorizontal(index, root_focus, true), 1871 .arrow_up => _ = self.moveDetailVertical(index, root_focus, false), 1872 .arrow_down => _ = self.moveDetailVertical(index, root_focus, true), 1873 else => return, 1874 } 1875 } 1876 1877 // page the detail scroll, then focus the first or last visible widget. 1878 fn pageDetail(self: *This, index: usize, root_focus: *Focus, delta: isize) void { 1879 if (!self.session.is_terminal) return; 1880 const sc = self.detailScroll(index); 1881 sc.y += delta; 1882 sc.clampToContent(); 1883 self.focusVisible(index, root_focus, delta > 0); 1884 } 1885 1886 // jump to the detail's first or last focusable widget. the terminal pins 1887 // its synthetic scroll too; the browser scrolls to the new focus itself. 1888 fn jumpDetail(self: *This, index: usize, root_focus: *Focus, to_end: bool) void { 1889 if (self.session.is_terminal) { 1890 const sc = self.detailScroll(index); 1891 sc.y = if (to_end) std.math.maxInt(isize) else 0; 1892 sc.clampToContent(); 1893 self.focusVisible(index, root_focus, to_end); 1894 } else { 1895 self.focusDetailEdge(index, root_focus, to_end); 1896 } 1897 } 1898 1899 fn focusDetailEdge(self: *This, index: usize, root_focus: *Focus, last: bool) void { 1900 const focus = self.detailInner(index).getFocus(); 1901 var chosen: ?usize = null; 1902 for (focus.children.keys(), focus.children.values()) |id, child| { 1903 if (!child.focus.focusable) continue; 1904 chosen = id; 1905 if (!last) break; 1906 } 1907 if (chosen) |id| root_focus.setFocus(id); 1908 } 1909 1910 fn detailRectVisible(self: *This, index: usize, rect: layout.IRect) bool { 1911 const sc = self.detailScroll(index); 1912 const viewport = sc.grid orelse return false; 1913 const top = sc.y; 1914 const bottom = top + @as(isize, @intCast(viewport.size.height - sc.bar_h)); 1915 return rect.y + @as(isize, @intCast(rect.size.height)) > top and rect.y < bottom; 1916 } 1917 1918 // focus the top- or bottom-most focusable widget intersecting the viewport. 1919 fn focusVisible(self: *This, index: usize, root_focus: *Focus, last: bool) void { 1920 const inner = self.detailInner(index); 1921 var offset: usize = 0; 1922 while (offset < inner.children.count()) : (offset += 1) { 1923 const child_index = if (last) inner.children.count() - 1 - offset else offset; 1924 if (self.focusVisibleChild(index, child_index, root_focus, last)) return; 1925 } 1926 } 1927 1928 fn focusVisibleChild(self: *This, index: usize, child_index: usize, root_focus: *Focus, last: bool) bool { 1929 const child = &self.detailInner(index).children.values()[child_index]; 1930 const child_rect = child.rect orelse return false; 1931 var chosen: ?usize = null; 1932 if (child.widget.getFocus().focusable and self.detailRectVisible(index, child_rect)) { 1933 const id = self.detailInner(index).children.keys()[child_index]; 1934 chosen = id; 1935 if (!last) { 1936 root_focus.setFocus(id); 1937 return true; 1938 } 1939 } 1940 1941 for (child.widget.getFocus().children.keys(), child.widget.getFocus().children.values()) |id, focus_child| { 1942 if (!focus_child.focus.focusable) continue; 1943 const rect: layout.IRect = .{ 1944 .x = child_rect.x + @as(isize, @intCast(focus_child.rect.x)), 1945 .y = child_rect.y + @as(isize, @intCast(focus_child.rect.y)), 1946 .size = focus_child.rect.size, 1947 }; 1948 if (!self.detailRectVisible(index, rect)) continue; 1949 chosen = id; 1950 if (!last) break; 1951 } 1952 if (chosen) |id| { 1953 root_focus.setFocus(id); 1954 return true; 1955 } 1956 return false; 1957 } 1958 1959 fn focusAdjacentVisible(self: *This, index: usize, root_focus: *Focus, down: bool) bool { 1960 const inner = self.detailInner(index); 1961 var next = self.focusedDetailChild(index, root_focus) orelse return false; 1962 while (true) { 1963 if (down) { 1964 next += 1; 1965 if (next >= inner.children.count()) return false; 1966 } else { 1967 if (next == 0) return false; 1968 next -= 1; 1969 } 1970 if (self.focusVisibleChild(index, next, root_focus, !down)) { 1971 self.focusDetailChild(index, next, !down, root_focus); 1972 return true; 1973 } 1974 } 1975 } 1976 1977 fn focusedDetailChild(self: *This, index: usize, root_focus: *Focus) ?usize { 1978 const inner = self.detailInner(index); 1979 var id = root_focus.grandchild_id orelse return null; 1980 while (inner.getFocus().children.get(id)) |child| { 1981 if (child.parent_id == inner.getFocus().id) return inner.children.getIndex(id); 1982 id = child.parent_id; 1983 } 1984 return null; 1985 } 1986 1987 fn detailChildFocusable(self: *This, index: usize, child_index: usize) bool { 1988 const child = &self.detailInner(index).children.values()[child_index].widget; 1989 const focus = child.getFocus(); 1990 if (focus.focusable) return true; 1991 for (focus.children.values()) |item| { 1992 if (item.focus.focusable) return true; 1993 } 1994 return false; 1995 } 1996 1997 fn focusDetailChild(self: *This, index: usize, child_index: usize, last: bool, root_focus: *Focus) void { 1998 const inner = self.detailInner(index); 1999 const child = &inner.children.values()[child_index]; 2000 var target = inner.children.keys()[child_index]; 2001 var rect = child.rect orelse return; 2002 switch (child.widget) { 2003 .repo_comment => |*comment| { 2004 if (comment.rowRect(last)) |row_rect| { 2005 rect.x += row_rect.x; 2006 rect.y += row_rect.y; 2007 rect.size = row_rect.size; 2008 } 2009 if (last) comment.focusBody(root_focus) else comment.focusMetadata(root_focus); 2010 }, 2011 .box => |*box| if (box.children.count() > 0) { 2012 target = box.children.keys()[if (last) box.children.count() - 1 else 0]; 2013 root_focus.setFocus(target); 2014 }, 2015 else => root_focus.setFocus(target), 2016 } 2017 const sc = self.detailScroll(index); 2018 if (sc.grid) |viewport| { 2019 const viewport_height = viewport.size.height - sc.bar_h; 2020 if (rect.size.height > viewport_height) { 2021 const bottom_aligned = rect.y + @as(isize, @intCast(rect.size.height - viewport_height)); 2022 sc.y = std.math.clamp(sc.y, rect.y, bottom_aligned); 2023 sc.clampToContent(); 2024 return; 2025 } 2026 } 2027 sc.scrollToRect(rect); 2028 } 2029 2030 fn moveDetailVertical(self: *This, index: usize, root_focus: *Focus, down: bool) bool { 2031 const inner = self.detailInner(index); 2032 const current = self.focusedDetailChild(index, root_focus) orelse return false; 2033 if (inner.children.values()[current].widget == .repo_comment) { 2034 const comment = &inner.children.values()[current].widget.repo_comment; 2035 if (down and !comment.bodyFocused()) { 2036 self.focusDetailChild(index, current, true, root_focus); 2037 return true; 2038 } 2039 if (!down and comment.bodyFocused()) { 2040 self.focusDetailChild(index, current, false, root_focus); 2041 return true; 2042 } 2043 } 2044 2045 var next = current; 2046 while (true) { 2047 if (down) { 2048 next += 1; 2049 if (next >= inner.children.count()) return false; 2050 } else { 2051 if (next == 0) return false; 2052 next -= 1; 2053 } 2054 if (!self.detailChildFocusable(index, next)) continue; 2055 self.focusDetailChild(index, next, !down, root_focus); 2056 return true; 2057 } 2058 } 2059 2060 fn moveCommentHorizontal(self: *This, index: usize, root_focus: *Focus, right: bool) bool { 2061 const inner = self.detailInner(index); 2062 const current = self.focusedDetailChild(index, root_focus) orelse return false; 2063 const child = &inner.children.values()[current]; 2064 if (child.widget == .repo_comment) { 2065 const moved = child.widget.repo_comment.moveHorizontal(root_focus, right); 2066 if (moved) if (child.rect) |rect| self.detailScroll(index).scrollToRect(rect); 2067 return moved; 2068 } 2069 const row = switch (child.widget) { 2070 .box => |*box| box, 2071 else => return false, 2072 }; 2073 const selected = row.getFocus().child_id orelse return false; 2074 const selected_index = row.children.getIndex(selected) orelse return false; 2075 const target = if (right) selected_index + 1 else selected_index -| 1; 2076 if (target == selected_index or target >= row.children.count()) return false; 2077 root_focus.setFocus(row.children.keys()[target]); 2078 if (child.rect) |rect| self.detailScroll(index).scrollToRect(rect); 2079 return true; 2080 } 2081 2082 // up/down (and tab/shift+tab) move between a form's fields; up from the 2083 // title crosses into the header tabs. the multiline description keeps 2084 // enter and any up/down that has a row to move to. 2085 fn formInput(self: *This, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 2086 const form = self.threadForm() orelse return; 2087 const cid = form.getFocus().child_id orelse return; 2088 const cur = form.children.getIndex(cid) orelse return; 2089 2090 if (cur == description_field_index) { 2091 const child = &form.children.values()[cur]; 2092 if (try multilineKeepsKey(&child.widget.text_input, allocator, key)) 2093 return child.widget.input(allocator, key, root_focus); 2094 } 2095 2096 switch (key) { 2097 .arrow_up, .back_tab => if (cur > 0) 2098 root_focus.setFocus(form.children.keys()[cur - 1]) 2099 else 2100 self.focusHeader(root_focus), 2101 .arrow_down, .tab => if (cur < submit_field_index) { 2102 root_focus.setFocus(form.children.keys()[cur + 1]); 2103 }, 2104 .enter => if (cur == submit_field_index) try self.submitForm(allocator), 2105 .mouse => |mouse| if (cur == submit_field_index) { 2106 const submit = &form.children.values()[cur].widget.submit_button; 2107 if (inp.leftClickOn(root_focus, submit.buttonId(), mouse)) try self.submitForm(allocator); 2108 }, 2109 else => try form.children.values()[cur].widget.input(allocator, key, root_focus), 2110 } 2111 } 2112 2113 fn commentFormInput(self: *This, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 2114 const form = self.threadForm() orelse return; 2115 const cid = form.getFocus().child_id orelse return; 2116 const cur = form.children.getIndex(cid) orelse return; 2117 const child = &form.children.values()[cur]; 2118 2119 if (cur == comment_body_field_index and try multilineKeepsKey(&child.widget.text_input, allocator, key)) 2120 return child.widget.input(allocator, key, root_focus); 2121 2122 switch (key) { 2123 .arrow_up, .back_tab => if (cur > comment_author_field_index) 2124 root_focus.setFocus(form.children.keys()[cur - 1]) 2125 else 2126 self.focusHeader(root_focus), 2127 .arrow_down, .tab => if (cur < comment_submit_field_index) 2128 root_focus.setFocus(form.children.keys()[cur + 1]), 2129 .enter => if (cur == comment_submit_field_index) try self.submitComment(allocator), 2130 .mouse => |mouse| if (cur == comment_submit_field_index) { 2131 const submit = &child.widget.submit_button; 2132 if (inp.leftClickOn(root_focus, submit.buttonId(), mouse)) try self.submitComment(allocator); 2133 }, 2134 else => try child.widget.input(allocator, key, root_focus), 2135 } 2136 } 2137 2138 fn removeFormInput(self: *This, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 2139 const form = self.removeForm() orelse return; 2140 const button_id = form.children.keys()[1]; 2141 switch (key) { 2142 .arrow_up, .back_tab => self.focusHeader(root_focus), 2143 .enter => try self.submitRemove(allocator), 2144 .mouse => |mouse| if (inp.leftClickOn(root_focus, button_id, mouse)) try self.submitRemove(allocator), 2145 else => {}, 2146 } 2147 } 2148 2149 // the multiline hunk inputs keep enter and any up/down that has a row to 2150 // move to; enter on a "use this" link is a navigation the host follows 2151 // before this runs. 2152 fn resolveInput(self: *This, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 2153 const form = self.threadForm() orelse return; 2154 const cid = form.getFocus().child_id orelse return; 2155 const cur = form.children.getIndex(cid) orelse return; 2156 const child = &form.children.values()[cur]; 2157 2158 if (child.widget == .text_input) { 2159 const ti = &child.widget.text_input; 2160 if (ti.options.multiline and try multilineKeepsKey(ti, allocator, key)) 2161 return child.widget.input(allocator, key, root_focus); 2162 } 2163 2164 const on_submit = child.widget == .submit_button; 2165 2166 switch (key) { 2167 .arrow_up, .back_tab => if (resolveStep(form, cur, false)) |i| self.focusResolveChild(form, i, root_focus) else self.focusHeader(root_focus), 2168 .arrow_down, .tab => if (resolveStep(form, cur, true)) |i| self.focusResolveChild(form, i, root_focus), 2169 .arrow_left, .arrow_right => switch (child.widget) { 2170 .box => |*row| { 2171 const row_cid = row.getFocus().child_id orelse return; 2172 const row_index = row.children.getIndex(row_cid) orelse return; 2173 if (key == .arrow_left) { 2174 if (row_index > 0) root_focus.setFocus(row.children.keys()[row_index - 1]); 2175 } else if (row_index + 1 < row.children.count()) { 2176 root_focus.setFocus(row.children.keys()[row_index + 1]); 2177 } 2178 }, 2179 else => try child.widget.input(allocator, key, root_focus), 2180 }, 2181 .enter => if (on_submit) try self.submitResolution(allocator), 2182 .mouse => |mouse| if (on_submit) { 2183 if (inp.leftClickOn(root_focus, child.widget.submit_button.buttonId(), mouse)) try self.submitResolution(allocator); 2184 }, 2185 else => try child.widget.input(allocator, key, root_focus), 2186 } 2187 } 2188 2189 // a multiline input keeps enter and any up/down that has a row to move to 2190 fn multilineKeepsKey(text_input: *wgt.TextInput(ui.Widget), allocator: std.mem.Allocator, key: Key) !bool { 2191 return switch (key) { 2192 .enter => true, 2193 .arrow_up => !try text_input.cursorOnFirstRow(allocator), 2194 .arrow_down => !try text_input.cursorOnLastRow(allocator), 2195 else => false, 2196 }; 2197 } 2198 2199 // the neighboring focusable resolve-form child, skipping the spacer and 2200 // the blank gap rows 2201 fn resolveStep(form: *wgt.Box(ui.Widget), cur: usize, down: bool) ?usize { 2202 var i = cur; 2203 while (true) { 2204 if (down) { 2205 i += 1; 2206 if (i >= form.children.count()) return null; 2207 } else { 2208 if (i == 0) return null; 2209 i -= 1; 2210 } 2211 switch (form.children.values()[i].widget) { 2212 .spacer, .text => continue, 2213 else => return i, 2214 } 2215 } 2216 } 2217 2218 // focus the resolve-form child at `i` (a version row focuses its selected 2219 // child) and keep it visible on the terminal 2220 fn focusResolveChild(self: *This, form: *wgt.Box(ui.Widget), i: usize, root_focus: *Focus) void { 2221 const child = &form.children.values()[i]; 2222 switch (child.widget) { 2223 .box => |*row| root_focus.setFocus(row.getFocus().child_id orelse form.children.keys()[i]), 2224 else => root_focus.setFocus(form.children.keys()[i]), 2225 } 2226 if (self.session.is_terminal) { 2227 if (self.resolveScroll()) |sc| { 2228 if (child.rect) |rect| sc.scrollToRect(rect); 2229 } 2230 } 2231 } 2232 2233 // re-emit the conflicted thread's event with the resolved content; any 2234 // event on the thread settles its conflict entry. this is the terminal 2235 // path; the web posts the form to the resolve route. 2236 fn submitResolution(self: *This, allocator: std.mem.Allocator) !void { 2237 if (comptime wasm) return; 2238 const io = self.session.io orelse return; 2239 const src = self.data.repo_source orelse return; 2240 const entry = self.data.selectedThread() orelse return; 2241 const author = (try self.session.eventAuthor()) orelse return; 2242 const form = self.threadForm() orelse return; 2243 2244 // gather the inputs by name; the d<n> hunk inputs appear in chunk order 2245 var title: ?[]u8 = null; 2246 var tags: ?[]u8 = null; 2247 var hunks: std.ArrayList([]const u8) = .empty; 2248 defer { 2249 if (title) |t| allocator.free(t); 2250 if (tags) |t| allocator.free(t); 2251 for (hunks.items) |h| allocator.free(h); 2252 hunks.deinit(allocator); 2253 } 2254 for (form.children.values()) |*child| switch (child.widget) { 2255 .text_input => |*ti| { 2256 const text = try ti.text(allocator); 2257 errdefer allocator.free(text); 2258 if (std.mem.eql(u8, ti.options.name, "title")) { 2259 title = text; 2260 } else if (std.mem.eql(u8, ti.options.name, "tags")) { 2261 tags = text; 2262 } else { 2263 try hunks.append(allocator, text); 2264 } 2265 }, 2266 else => {}, 2267 }; 2268 2269 const id_bytes = try evt.parseEventId(entry.id); 2270 2271 switch (src.repo_kind) { 2272 inline else => |repo_kind| { 2273 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); 2274 defer any_repo.deinit(io, allocator); 2275 switch (any_repo) { 2276 inline else => |*repo| { 2277 const change: Event.Update = if (comptime kind == .patch) 2278 .{ .resolve = .{ 2279 .title = title, 2280 .tags = tags, 2281 .hunks = hunks.items, 2282 .theirs = self.data.theirs_picks, 2283 } } 2284 else 2285 .{ .resolve = .{ 2286 .title = title, 2287 .tags = tags, 2288 .hunks = hunks.items, 2289 } }; 2290 Event.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &id_bytes, change, author) catch |err| switch (err) { 2291 // leave the form up for correction 2292 error.InvalidFields => return, 2293 else => |e| return e, 2294 }; 2295 }, 2296 } 2297 }, 2298 } 2299 2300 const route = listRoute(self.data.identity, entryStatus(entry.*), "", entry.id) orelse return; 2301 try self.session.navigate(route); 2302 } 2303 2304 // commit a new or edited comment and navigate to its permalink. this is the 2305 // terminal path; the web posts the form to its comment route. 2306 fn submitComment(self: *This, allocator: std.mem.Allocator) !void { 2307 if (comptime wasm) return; 2308 const io = self.session.io orelse return; 2309 const src = self.data.repo_source orelse return; 2310 const author = (try self.session.eventAuthor()) orelse return; 2311 const form = self.threadForm() orelse return; 2312 const body_input = &form.children.values()[comment_body_field_index].widget.text_input; 2313 const body = try body_input.text(allocator); 2314 defer allocator.free(body); 2315 if (!evt.Comment.fieldsValid(body)) return; 2316 2317 var thread_id: [evt.event_id_size * 2]u8 = undefined; 2318 @memcpy(&thread_id, self.data.selected_id); 2319 const parent_id = if (self.data.comment_id.len == 0) thread_id else blk: { 2320 var parent: [evt.event_id_size * 2]u8 = undefined; 2321 @memcpy(&parent, self.data.comment_id); 2322 break :blk parent; 2323 }; 2324 var event_id_hex: [evt.event_id_size * 2]u8 = undefined; 2325 const editing = self.data.view == .edit_comment; 2326 switch (src.repo_kind) { 2327 inline else => |repo_kind| { 2328 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); 2329 defer any_repo.deinit(io, allocator); 2330 switch (any_repo) { 2331 inline else => |*repo| if (editing) { 2332 const comment_id = evt.parseEventId(self.data.comment_id) catch return; 2333 try evt.Comment.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &thread_id, &comment_id, body, author); 2334 event_id_hex = std.fmt.bytesToHex(comment_id, .lower); 2335 } else { 2336 event_id_hex = try evt.Comment.create(repo_kind, repo.self_repo_opts, io, allocator, repo, &thread_id, &parent_id, body, author); 2337 }, 2338 } 2339 }, 2340 } 2341 2342 body_input.clear(allocator); 2343 const route = ui.RoutablePage.repoThreadCommentRoute(kind, self.data.identity, &thread_id, &event_id_hex, 0) orelse return; 2344 try self.session.navigate(route); 2345 } 2346 2347 // the terminal submit paths: the new form commits a new thread event, the 2348 // edit form re-emits the selected thread's event with the form's content. 2349 // the web posts the forms to their routes instead. 2350 fn submitForm(self: *This, allocator: std.mem.Allocator) !void { 2351 if (self.data.view == .edit) { 2352 try self.submitEditedThread(allocator); 2353 } else { 2354 try self.submitNewThread(allocator); 2355 } 2356 } 2357 2358 // commit the new thread to the repo's events branch and navigate to it. 2359 // this is the terminal path; the web posts the form to the thread route, 2360 // so the wasm side never runs (or compiles) the repo access below. 2361 fn submitNewThread(self: *This, allocator: std.mem.Allocator) !void { 2362 if (comptime wasm) return; 2363 const io = self.session.io orelse return; 2364 const src = self.data.repo_source orelse return; 2365 const author = (try self.session.eventAuthor()) orelse return; 2366 2367 const form = self.threadForm() orelse return; 2368 const title_input = &form.children.values()[title_field_index].widget.text_input; 2369 const tags_input = &form.children.values()[tags_field_index].widget.text_input; 2370 const description_input = &form.children.values()[description_field_index].widget.text_input; 2371 2372 const title = try title_input.text(allocator); 2373 defer allocator.free(title); 2374 const tags = try tags_input.text(allocator); 2375 defer allocator.free(tags); 2376 const description = try description_input.text(allocator); 2377 defer allocator.free(description); 2378 2379 if (!Event.fieldsValid(title, tags)) return; 2380 2381 if (comptime kind == .patch) { 2382 const event_id_hex = try Data.createDraft(self.data, self.session, allocator, title, tags, description); 2383 title_input.clear(allocator); 2384 tags_input.clear(allocator); 2385 description_input.clear(allocator); 2386 const route = ui.RoutablePage.repoThreadCommentsRoute(kind, self.data.identity, &event_id_hex, 0) orelse return; 2387 try self.session.navigate(route); 2388 return; 2389 } 2390 2391 var id_bytes: [evt.event_id_size]u8 = undefined; 2392 io.random(&id_bytes); 2393 const event_id_hex = std.fmt.bytesToHex(id_bytes, .lower); 2394 2395 const event = evt.EventWithId{ 2396 .id = event_id_hex, 2397 .timestamp = @intCast(std.Io.Timestamp.now(io, .real).toSeconds()), 2398 .author = author, 2399 .event = @unionInit(evt.Event, @tagName(kind), Event{ 2400 .title = title, 2401 .description = description, 2402 .tags = tags, 2403 }), 2404 }; 2405 2406 switch (src.repo_kind) { 2407 inline else => |repo_kind| { 2408 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); 2409 defer any_repo.deinit(io, allocator); 2410 switch (any_repo) { 2411 inline else => |*repo| try evt.consume(.repo, repo_kind, repo.self_repo_opts, io, allocator, repo, evt.events_ref, &.{event}), 2412 } 2413 }, 2414 } 2415 2416 // wipe the form so a return visit starts fresh 2417 title_input.clear(allocator); 2418 tags_input.clear(allocator); 2419 description_input.clear(allocator); 2420 2421 const route = listRoute(self.data.identity, @enumFromInt(0), "", &event_id_hex) orelse return; 2422 try self.session.navigate(route); 2423 } 2424 2425 // re-emit the selected thread's event with the form's content (status 2426 // preserved), then reload the thread's page. this is the terminal path; 2427 // the web posts the form to the edit route. 2428 fn submitEditedThread(self: *This, allocator: std.mem.Allocator) !void { 2429 if (comptime wasm) return; 2430 const io = self.session.io orelse return; 2431 const src = self.data.repo_source orelse return; 2432 const entry = self.data.selectedThread() orelse return; 2433 const author = (try self.session.eventAuthor()) orelse return; 2434 2435 const form = self.threadForm() orelse return; 2436 const title_input = &form.children.values()[title_field_index].widget.text_input; 2437 const tags_input = &form.children.values()[tags_field_index].widget.text_input; 2438 const description_input = &form.children.values()[description_field_index].widget.text_input; 2439 2440 const title = try title_input.text(allocator); 2441 defer allocator.free(title); 2442 const tags = try tags_input.text(allocator); 2443 defer allocator.free(tags); 2444 const description = try description_input.text(allocator); 2445 defer allocator.free(description); 2446 2447 if (!Event.fieldsValid(title, tags)) return; 2448 2449 const id_bytes = try evt.parseEventId(entry.id); 2450 2451 switch (src.repo_kind) { 2452 inline else => |repo_kind| { 2453 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); 2454 defer any_repo.deinit(io, allocator); 2455 switch (any_repo) { 2456 inline else => |*repo| if (has_status) 2457 try Event.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &id_bytes, .{ .fields = .{ 2458 .title = title, 2459 .tags = tags, 2460 .description = description, 2461 } }, author) 2462 else 2463 try Event.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &id_bytes, title, tags, description, author), 2464 } 2465 }, 2466 } 2467 2468 const route = listRoute(self.data.identity, entryStatus(entry.*), "", entry.id) orelse return; 2469 try self.session.navigate(route); 2470 } 2471 2472 fn submitRemove(self: *This, allocator: std.mem.Allocator) !void { 2473 const comment = self.data.comment_id.len != 0; 2474 const event_id = if (comment) self.data.comment_id else self.data.selected_id; 2475 const id = evt.parseEventId(event_id) catch return; 2476 try self.removeEvent(allocator, if (comment) .comment else kind, id); 2477 } 2478 2479 fn removeEvent(self: *This, allocator: std.mem.Allocator, event_kind: evt.EventKind, id: [evt.event_id_size]u8) !void { 2480 if (comptime wasm) return; 2481 const io = self.session.io orelse return; 2482 const src = self.data.repo_source orelse return; 2483 const author = (try self.session.eventAuthor()) orelse return; 2484 2485 switch (src.repo_kind) { 2486 inline else => |repo_kind| { 2487 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); 2488 defer any_repo.deinit(io, allocator); 2489 switch (any_repo) { 2490 inline else => |*repo| try evt.remove(.repo, repo_kind, repo.self_repo_opts, io, allocator, repo, &id, event_kind, author), 2491 } 2492 }, 2493 } 2494 2495 const id_hex = std.fmt.bytesToHex(id, .lower); 2496 const route = ui.RoutablePage.repoEventsRoute(self.data.identity, .removed, event_kind, &id_hex) orelse return; 2497 try self.session.navigate(route); 2498 } 2499 2500 // flip the shown thread's status by re-emitting its event, then reload the 2501 // page rooted at the thread so the view reflects the change. this is the 2502 // terminal path; the web posts the button's form to the status route. 2503 fn toggleStatus(self: *This, allocator: std.mem.Allocator, index: usize) !void { 2504 if (comptime !has_status) return; 2505 if (comptime wasm) return; 2506 const io = self.session.io orelse return; 2507 const src = self.data.repo_source orelse return; 2508 const sel = self.detailed_index[index] orelse return; 2509 const entry = self.window(index).items[sel]; 2510 const author = (try self.session.eventAuthor()) orelse return; 2511 2512 const status = (statusChange(entryStatus(entry)) orelse return).status; 2513 2514 const id_bytes = try evt.parseEventId(entry.id); 2515 2516 switch (src.repo_kind) { 2517 inline else => |repo_kind| { 2518 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); 2519 defer any_repo.deinit(io, allocator); 2520 switch (any_repo) { 2521 inline else => |*repo| try Event.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &id_bytes, .{ .status = status }, author), 2522 } 2523 }, 2524 } 2525 2526 const route = listRoute(self.data.identity, status, "", entry.id) orelse return; 2527 try self.session.navigate(route); 2528 } 2529 2530 // arrow keys move the tag selection; at the flow's edges focus crosses to 2531 // the neighboring widgets. 2532 fn tagsInput(self: *This, index: usize, child_index: usize, tf: *ui.TagFlow, key: Key, root_focus: *Focus) void { 2533 const cid = tf.focus.child_id orelse return; 2534 const cur = tf.indexOfFocusId(cid) orelse return; 2535 const count = tf.text_boxes.items.len; 2536 switch (key) { 2537 .arrow_left => if (cur > 0) self.focusTag(index, child_index, tf, root_focus, cur - 1) else self.focusList(index, root_focus), 2538 .arrow_right => if (cur + 1 < count) self.focusTag(index, child_index, tf, root_focus, cur + 1), 2539 .arrow_up => if (tf.rowStep(cur, false)) |i| { 2540 self.focusTag(index, child_index, tf, root_focus, i); 2541 } else { 2542 _ = self.moveDetailVertical(index, root_focus, false); 2543 }, 2544 .arrow_down => if (tf.rowStep(cur, true)) |i| { 2545 self.focusTag(index, child_index, tf, root_focus, i); 2546 } else { 2547 _ = self.moveDetailVertical(index, root_focus, true); 2548 }, 2549 .home => self.focusTag(index, child_index, tf, root_focus, 0), 2550 .end => self.focusTag(index, child_index, tf, root_focus, count - 1), 2551 else => {}, 2552 } 2553 } 2554 2555 // the tool row's first child after the spacer that pushes it right 2556 const first_in_row_index: usize = 1; 2557 2558 fn toolRowFocused(self: *This, index: usize) bool { 2559 const outer = self.detailOuter(index); 2560 const cid = outer.getFocus().child_id orelse return false; 2561 return outer.children.getIndex(cid) == tool_row_index; 2562 } 2563 2564 fn focusTag(self: *This, index: usize, child_index: usize, tf: *ui.TagFlow, root_focus: *Focus, item: usize) void { 2565 root_focus.setFocus(tf.text_boxes.items[item].getFocus().id); 2566 // keep the tag visible on the terminal: its rect offset by the flow's 2567 // position in the pane. 2568 if (self.session.is_terminal and item < tf.rects.items.len) { 2569 if (self.detailInner(index).children.values()[child_index].rect) |flow_rect| { 2570 var rect = tf.rects.items[item]; 2571 rect.x += flow_rect.x; 2572 rect.y += flow_rect.y; 2573 self.detailScroll(index).scrollToRect(rect); 2574 } 2575 } 2576 } 2577 2578 // return to the tool row's last-focused button (the header when the 2579 // description page shows no row). 2580 fn focusToolRow(self: *This, index: usize, root_focus: *Focus) void { 2581 const cid = self.toolRow(index).getFocus().child_id orelse return self.focusHeader(root_focus); 2582 root_focus.setFocus(cid); 2583 } 2584 2585 // enter the detail pane. an empty pane can't be entered. 2586 fn focusDetail(self: *This, index: usize, root_focus: *Focus) void { 2587 if (self.detailInner(index).children.count() == 0) return; 2588 root_focus.setFocus(self.detailOuter(index).getFocus().id); 2589 } 2590 2591 // return to the list. 2592 fn focusList(self: *This, index: usize, root_focus: *Focus) void { 2593 root_focus.setFocus(self.listScroll(index).getFocus().id); 2594 } 2595 2596 // cross to the header tabs above the stack. 2597 fn focusHeader(self: *This, root_focus: *Focus) void { 2598 root_focus.setFocus(self.box.children.keys()[header_index]); 2599 } 2600 2601 pub fn clearGrid(self: *This) void { 2602 self.box.clearGrid(); 2603 } 2604 2605 pub fn getGrid(self: This) ?Grid { 2606 return self.box.getGrid(); 2607 } 2608 2609 pub fn getFocus(self: *This) *Focus { 2610 return self.box.getFocus(); 2611 } 2612 2613 // for the parent's "scroll up at the top jumps to the header" check: at the 2614 // top only while the header tabs hold focus, so up from the split 2615 // crosses into the tabs first. 2616 pub fn getSelectedIndex(self: *This) ?usize { 2617 return if (self.headerActive()) 0 else 1; 2618 } 2619 }; 2620 } 2621