╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ ui.zig ───────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
2021 .home_user => !a.eql(b), 2022 else => false, 2023 }, 2024 else => false, 2025 }; 2026 } 2027 }; 2028 2029 // a `RefOrOid` resolved against an on-disk repo to the commit oid it points at 2030 fn isUnreserved(c: u8) bool { 2031 return std.ascii.isAlphanumeric(c) or c == '-' or c == '.' or c == '_' or c == '~'; 2032 } 2033 2034 // percent-encode a ref name for use as a single url path segment. 2035 pub fn urlEncodeRef(aa: std.mem.Allocator, raw: []const u8) ![]const u8 { 2036 var out: std.Io.Writer.Allocating = .init(aa); 2037 try std.Uri.Component.percentEncode(&out.writer, raw, isUnreserved); 2038 return out.written(); 2039 } 2040 2041 // a resolved ref/oid for a repo opened with `repo_kind`/`repo_opts` 2042 pub fn ResolvedRefOrOid(comptime repo_kind: rp.RepoKind, comptime repo_opts: rp.RepoOpts(repo_kind)) type { 2043 return struct { 2044 pub const hex_len = hash.hexLen(repo_opts.hash); 2045 2046 // the concrete ref/oid (a null request resolves to the default branch) 2047 ref_or_oid: RoutablePage.RefOrOid, 2048 // its branch/tag name or oid, url-encoded (ref names may contain '/', so 2049 // the route layer — which splits on '/' — stores them encoded). duped 2050 // into `aa`. 2051 value: []const u8, 2052 // the commit oid it points at 2053 oid: [hex_len]u8, 2054 2055 const Self = @This(); 2056 2057 // resolve a requested ref/oid (null = the repo's default branch) to a 2058 // concrete ref/oid plus the commit oid it points at. `requested_value` is 2059 // the url-encoded form (as it appears in the route). null when it doesn't 2060 // resolve (an unknown branch/tag, or a malformed/unknown oid). 2061 pub fn init( 2062 repo: *rp.Repo(repo_kind, repo_opts), 2063 io: std.Io, 2064 aa: std.mem.Allocator, 2065 requested_ref_or_oid: ?RoutablePage.RefOrOid, 2066 requested_value: []const u8, 2067 ) !?Self { 2068 var ref_or_oid: RoutablePage.RefOrOid = requested_ref_or_oid orelse .branch; 2069 // the decoded ref name / oid to look up. a named ref arrives url-encoded. 2070 var value: []const u8 = if (requested_ref_or_oid == null) 2071 requested_value 2072 else 2073 std.Uri.percentDecodeInPlace(try aa.dupe(u8, requested_value)); 2074 // no ref named: fall back to HEAD's branch (or its oid when detached). 2075 var head_buf: [rf.MAX_REF_CONTENT_SIZE]u8 = undefined; 2076 if (requested_ref_or_oid == null) { 2077 if (repo.head(io, &head_buf)) |head| switch (head) { 2078 .ref => |r| { 2079 ref_or_oid = .branch; 2080 value = r.name; 2081 }, 2082 .oid => |o| { 2083 ref_or_oid = .object; 2084 value = o; 2085 }, 2086 } else |_| {} 2087 } 2088 2089 var oid: [hex_len]u8 = undefined; 2090 switch (ref_or_oid) { 2091 .object => { 2092 if (value.len != hex_len) return null; 2093 @memcpy(&oid, value); 2094 }, 2095 .branch, .tag => { 2096 const ref_kind: rf.RefKind = if (ref_or_oid == .branch) .head else .tag; 2097 oid = (repo.readRef(io, .{ .kind = ref_kind, .name = value }) catch null) orelse return null; 2098 }, 2099 } 2100 // store url-encoded so the route layer can hold the value verbatim. 2101 return .{ .ref_or_oid = ref_or_oid, .value = try urlEncodeRef(aa, value), .oid = oid }; 2102 } 2103 }; 2104 } 2105 2106 // who a repo page is built for, which picks the actions it shows 2107 pub const Viewer = struct { 2108 role: evt.Repo.Role, 2109 email: []const u8, 2110 2111 // the session's viewer of a repo it holds `role` in, or null when logged 2112 // out. local mode owns everything. 2113 pub fn init(session: *Session, arena: *std.heap.ArenaAllocator, role: evt.Repo.Role) !?Viewer { 2114 if (session.data.host_kind == .local) return .{ .role = .owner, .email = "" }; 2115 const user_id = session.userId() orelse return null; 2116 const moment = session.haxy_moment orelse unreachable; 2117 const user = (try activeUser(moment, arena, user_id)) orelse return null; 2118 return .{ .role = role, .email = user.event.email }; 2119 } 2120 }; 2121 2122 // a repo a page shows, and what its viewer may do there 2123 pub const RepoHandle = struct { 2124 location: RoutablePage.RepoLocation, 2125 viewer: ?Viewer = null, 2126 // only a public repo can be cloned over http 2127 read_access: evt.Repo.Access = .private, 2128 2129 pub fn dupe(self: RepoHandle, allocator: std.mem.Allocator) !RepoHandle { 2130 var copy = self; 2131 copy.location = try self.location.dupe(allocator); 2132 return copy; 2133 } 2134 2135 // pushing, and seeing the events and undo tabs. local mode is always owner. 2136 pub fn canWrite(self: RepoHandle) bool { 2137 const viewer = self.viewer orelse return false; 2138 return viewer.role.atLeast(.write); 2139 } 2140 2141 pub fn canUndo(self: RepoHandle) bool { 2142 const viewer = self.viewer orelse return false; 2143 return viewer.role == .owner; 2144 } 2145 }; 2146 2147 // where a repo page reads its on-disk repo from 2148 pub const RepoSource = struct { 2149 path: []const u8, 2150 repo_kind: rp.RepoKind, 2151 // the user's global git config, so local mode picks up their identity and 2152 // their signing and ssh settings. null on the server paths, which must not 2153 // read whoever runs the server. 2154 global_config_path: ?[]const u8 = null, 2155 2156 pub fn localInitOpts(self: RepoSource) rp.InitOpts { 2157 return .{ .path = self.path, .global_config_path = self.global_config_path }; 2158 } 2159 2160 pub fn hasBranch(self: RepoSource, io: std.Io, allocator: std.mem.Allocator, branch: []const u8) !bool { 2161 if (!xit.ref.validateName(branch)) return false; 2162 return switch (self.repo_kind) { 2163 inline else => |repo_kind| blk: { 2164 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, self.localInitOpts()); 2165 defer any_repo.deinit(io, allocator); 2166 break :blk switch (any_repo) { 2167 inline else => |*repo| (try repo.readRef(io, .{ .kind = .head, .name = branch })) != null, 2168 }; 2169 }, 2170 }; 2171 } 2172 2173 // whether the actor may change the event `id` names 2174 pub fn canModify( 2175 self: RepoSource, 2176 io: std.Io, 2177 allocator: std.mem.Allocator, 2178 actor: Actor, 2179 kind: evt.EventKind, 2180 id: *const [evt.event_id_size]u8, 2181 ) !bool { 2182 // a role that may change an event with no author needs no read 2183 if (actor.role.canModify(actor.author.email, null)) return true; 2184 2185 var arena = std.heap.ArenaAllocator.init(allocator); 2186 defer arena.deinit(); 2187 return switch (self.repo_kind) { 2188 inline else => |repo_kind| blk: { 2189 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, self.localInitOpts()); 2190 defer any_repo.deinit(io, allocator); 2191 break :blk switch (any_repo) { 2192 inline else => |*repo| actor.role.canModify(actor.author.email, try evt.readAuthorEmail(repo_kind, repo.self_repo_opts, io, allocator, &arena, repo, kind, id)), 2193 }; 2194 }, 2195 }; 2196 } 2197 }; 2198 2199 // who a write acts as and the role they hold in its repo 2200 // the thread button that changes the viewer's notifications, named by its label 2201 pub const NotifyAction = enum { subscribe, mute }; 2202 2203 pub const Actor = struct { 2204 author: evt.CommitAuthor, 2205 // local mode has no accounts, so it has no user id 2206 user_id: ?[evt.event_id_size]u8 = null, 2207 role: evt.Repo.Role, 2208 // the repo owner's id, which local mode lacks 2209 repo_user_id: ?[evt.event_id_size]u8 = null, 2210 }; 2211 2212 pub const Authorization = union(enum) { 2213 actor: Actor, 2214 login_required, 2215 repo_not_found, 2216 forbidden, 2217 }; 2218 2219 // the account `user_id` names, or null once it has been removed. a user id is 2220 // checked here wherever one enters, so the role lookups can trust theirs. 2221 pub fn activeUser( 2222 moment: evt.AdminDB.HashMap(.read_only), 2223 arena: *std.heap.ArenaAllocator, 2224 user_id: [evt.event_id_size]u8, 2225 ) !?evt.User.Record { 2226 const user = (try evt.User.readById(evt.AdminDB, evt.admin_repo_opts.hash, moment, arena, &user_id)) orelse return null; 2227 return if (user.removed) null else user; 2228 } 2229 2230 // what the typed assignee resolves to 2231 pub const AssigneeLookup = union(enum) { 2232 email: []const u8, 2233 failure: Session.FormFeedback.AssignmentFailure, 2234 }; 2235 2236 // the email `text` assigns: itself when it holds an @, else the email of the 2237 // user it names. a null moment is local mode, which has no users to look up. 2238 pub fn assigneeEmail(moment_maybe: ?evt.AdminDB.HashMap(.read_only), arena: *std.heap.ArenaAllocator, text: []const u8) !AssigneeLookup { 2239 const trimmed = std.mem.trim(u8, text, " \t\r\n"); 2240 if (std.mem.indexOfScalar(u8, trimmed, '@') != null) return .{ .email = trimmed }; 2241 const moment = moment_maybe orelse return .{ .failure = .not_an_email }; 2242 const user_id = (try evt.User.readIdByName(evt.AdminDB, evt.admin_repo_opts.hash, moment, trimmed)) orelse return .{ .failure = .not_found }; 2243 const user = (try activeUser(moment, arena, user_id)) orelse return .{ .failure = .not_found }; 2244 return .{ .email = user.event.email }; 2245 } 2246 2247 // whether the user may write to the repo `identity` names with at least 2248 // `min_role`, and to the `thread_kind` tab when there is one 2249 pub fn authorizeUser( 2250 io: std.Io, 2251 moment: evt.AdminDB.HashMap(.read_only), 2252 arena: *std.heap.ArenaAllocator, 2253 users_dir: []const u8, 2254 user_id: [evt.event_id_size]u8, 2255 identity: []const u8, 2256 min_role: evt.Repo.Role, 2257 thread_kind: ?evt.EventKind, 2258 ) !Authorization { 2259 const user = (try activeUser(moment, arena, user_id)) orelse return .login_required; 2260 2261 const owner_repo = evt.parseOwnerRepoPath(identity) orelse return .repo_not_found; 2262 const repo = (try evt.readRepoByOwnerAndName(io, arena.child_allocator, arena, moment, users_dir, owner_repo.owner, owner_repo.name, user_id)) orelse return .repo_not_found; 2263 // a repo the user can't read doesn't exist to them 2264 const role = repo.role orelse return .repo_not_found; 2265 if (!role.atLeast(min_role)) return .forbidden; 2266 if (thread_kind) |kind| { 2267 // a tab that is off doesn't exist 2268 const tab_role = repo.repo.event.threadRole(kind) orelse return .repo_not_found; 2269 if (!role.atLeast(tab_role)) return .forbidden; 2270 } 2271 return .{ .actor = .{ 2272 .author = .{ .name = user.event.name, .email = user.event.email }, 2273 .user_id = user_id, 2274 .role = role, 2275 .repo_user_id = try evt.parseEventId(&repo.repo.event.user_id), 2276 } }; 2277 } 2278 2279 // events created in local mode have no account behind them, so the commit 2280 // author comes from the repo's config, which the global config feeds into. 2281 pub const local_author_fallback = evt.CommitAuthor{ .name = "haxy", .email = "user@haxy" }; 2282 2283 pub fn localAuthor( 2284 src: RepoSource, 2285 io: std.Io, 2286 allocator: std.mem.Allocator, 2287 arena: *std.heap.ArenaAllocator, 2288 ) !evt.CommitAuthor { 2289 switch (src.repo_kind) { 2290 inline else => |repo_kind| { 2291 var any_repo = rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()) catch return local_author_fallback; 2292 defer any_repo.deinit(io, allocator); 2293 2294 switch (any_repo) { 2295 inline else => |*repo| { 2296 var config = try repo.listConfig(io, allocator); 2297 defer config.deinit(); 2298 2299 const user = config.sections.get("user") orelse return local_author_fallback; 2300 const name = user.get("name") orelse return local_author_fallback; 2301 const email = user.get("email") orelse return local_author_fallback; 2302 return .{ 2303 .name = try arena.allocator().dupe(u8, name), 2304 .email = try arena.allocator().dupe(u8, email), 2305 }; 2306 }, 2307 } 2308 }, 2309 } 2310 } 2311 2312 // per-connection mutable state. each SSH session / web session / local TUI 2313 // run gets its own. 2314 pub const Session = struct { 2315 data: Data = .{}, 2316 // session-lifetime allocations: login/user_id, persisted prefs, queued 2317 // actions. lives as long as the connection. 2318 arena: *std.heap.ArenaAllocator, 2319 // current page's allocations: page data and the page-scoped strings widgets 2320 // build (form actions, links). owned by Nav, swapped on each navigation, so 2321 // it doesn't accumulate over a long-lived session. on the web/wasm paths, 2322 // where a render is one-shot, this points at the same arena as `arena`. 2323 page_arena: *std.heap.ArenaAllocator, 2324 haxy_moment: ?evt.AdminDB.HashMap(.read_only) = null, // db cursor (null on the wasm side) 2325 // filesystem io and the path to <server>/repos, for opening on-disk repos 2326 // during page construction. both null on wasm, which has no filesystem and 2327 // rebuilds pages from the serialized snapshot rather than from disk. 2328 io: ?std.Io = null, 2329 admin_repo: ?*rp.Repo(.xit, evt.admin_repo_opts) = null, 2330 users_dir: ?[]const u8 = null, 2331 // the notify worker's queue, null where no worker runs 2332 dirty_repos: ?*notify.DirtyRepos = null, 2333 // the single on-disk repo this session views, when running in local mode 2334 // (haxy invoked with no arguments inside a repo). null on the server paths, 2335 // which resolve repos from the admin db instead. 2336 local: ?RepoSource = null, 2337 // focus id -> the live TextInput, refreshed each frame by the views that own 2338 // inputs. web/wasm form handling looks widgets up here by focus id. 2339 text_inputs: std.AutoHashMapUnmanaged(usize, *wgt.TextInput) = .empty, 2340 back: enum { unavailable, available, requested } = .unavailable, 2341 refresh_requested: bool = false, // set by input (ctrl+r) 2342 // a requested forward navigation. set this (via navigate) to move to a new 2343 // page; Nav.sync builds it and then copies it into current_page, clearing 2344 // this back to null. setting current_page directly only updates the url and 2345 // does not navigate. 2346 next_page: ?RoutablePage = null, 2347 is_terminal: bool = false, // true on remote SSH and local TUI 2348 color: bool = true, // false when NO_COLOR is set, which also hides the art 2349 // the terminal's reported default background, which the art blends toward 2350 terminal_background: ?Grid.Color.Rgb = null, 2351 // port the web UI is served on, for the TUI/SSH footer's "http://localhost:<port>..." 2352 // url. null on the web itself (no footer there). 2353 web_port: ?u16 = null, 2354 // the authorized_keys line of the key an ssh session connected with 2355 ssh_key: ?[]const u8 = null, 2356 // a host operation requested by a widget 2357 host_request: ?HostRequest = null, 2358 quit_requested: bool = false, 2359 2360 pub const HostRequest = union(enum) { 2361 show_copyable_text: []const u8, 2362 sync_events, 2363 undo: RoutablePage.RepoUndoRoute, 2364 }; 2365 2366 pub const FormFeedback = union(enum) { 2367 pub const LoginFailure = enum { unknown_user, wrong_password }; 2368 pub const ThreadFailure = enum { required_title }; 2369 pub const PatchFailure = enum { 2370 required_title, 2371 invalid_target_branch, 2372 invalid_source_branch, 2373 same_branch, 2374 unrelated_branches, 2375 2376 pub fn fromError(err: anyerror) ?PatchFailure { 2377 return switch (err) { 2378 error.InvalidTargetBranch => .invalid_target_branch, 2379 error.InvalidSourceBranch => .invalid_source_branch, 2380 error.SameBranch => .same_branch, 2381 error.UnrelatedBranches => .unrelated_branches, 2382 else => null, 2383 }; 2384 } 2385 }; 2386 2387 pub const RepoFailure = enum { 2388 required_name, 2389 invalid_name, 2390 name_taken, 2391 2392 pub fn fromError(err: anyerror) ?RepoFailure { 2393 return switch (err) { 2394 error.NameEmpty => .required_name, 2395 error.NameTooLong, error.InvalidName => .invalid_name, 2396 error.NameTaken => .name_taken, 2397 else => null, 2398 }; 2399 } 2400 }; 2401 2402 pub const MembersFailure = enum { not_found, already_added }; 2403 2404 pub const BranchesFailure = enum { 2405 not_found, 2406 already_added, 2407 too_many, 2408 too_long, 2409 2410 pub fn fromError(err: anyerror) ?BranchesFailure { 2411 return switch (err) { 2412 error.BranchProtected => .already_added, 2413 error.TooManyProtectedBranches => .too_many, 2414 else => null, 2415 }; 2416 } 2417 }; 2418 2419 pub const AssignmentFailure = enum { not_found, not_an_email, already_assigned }; 2420 2421 pub const KeyFailure = enum { 2422 invalid, 2423 unsupported, 2424 already_added, 2425 too_many, 2426 2427 pub fn fromError(err: anyerror) ?KeyFailure { 2428 return switch (err) { 2429 error.InvalidKey => .invalid, 2430 error.UnsupportedKey => .unsupported, 2431 error.KeyAdded => .already_added, 2432 error.TooManyKeys => .too_many, 2433 else => null, 2434 }; 2435 } 2436 }; 2437 2438 pub const UserFailure = enum { 2439 required_name, 2440 invalid_name, 2441 name_taken, 2442 required_email, 2443 email_taken, 2444 required_password, 2445 password_mismatch, 2446 wrong_password, 2447 2448 pub fn fromError(err: anyerror) ?UserFailure { 2449 return switch (err) { 2450 error.NameEmpty => .required_name, 2451 error.NameTooShort, error.NameTooLong, error.InvalidName => .invalid_name, 2452 error.NameTaken => .name_taken, 2453 error.EmailEmpty => .required_email, 2454 error.EmailTaken => .email_taken, 2455 error.PasswordEmpty => .required_password, 2456 error.PasswordMismatch => .password_mismatch, 2457 error.WrongPassword => .wrong_password, 2458 else => null, 2459 }; 2460 } 2461 }; 2462 2463 login: struct { 2464 failure: LoginFailure, 2465 username: []const u8, 2466 }, 2467 user: struct { 2468 failure: UserFailure, 2469 // passwords are never retained 2470 fields: struct { 2471 name: []const u8, 2472 email: []const u8, 2473 }, 2474 }, 2475 user_settings: struct { 2476 failure: UserFailure, 2477 // passwords are never retained 2478 fields: struct { email: []const u8 }, 2479 }, 2480 user_keys: struct { 2481 failure: KeyFailure, 2482 key: []const u8, 2483 }, 2484 repo: struct { 2485 failure: RepoFailure, 2486 fields: struct { 2487 name: []const u8, 2488 description: []const u8, 2489 hash: hash.HashKind, 2490 access: evt.Repo.Access, 2491 }, 2492 }, 2493 repo_settings: struct { 2494 failure: RepoFailure, 2495 fields: struct { 2496 name: []const u8, 2497 description: []const u8, 2498 access: evt.Repo.Access, 2499 discuss_role: ?evt.Repo.Role, 2500 issue_role: ?evt.Repo.Role, 2501 patch_role: ?evt.Repo.Role, 2502 }, 2503 }, 2504 repo_members: struct { 2505 failure: MembersFailure, 2506 name: []const u8, 2507 }, 2508 repo_branches: struct { 2509 failure: BranchesFailure, 2510 name: []const u8, 2511 }, 2512 assignment: struct { 2513 failure: AssignmentFailure, 2514 text: []const u8, 2515 }, 2516 issue: struct { 2517 failure: ThreadFailure, 2518 fields: ?struct { 2519 title: []const u8, 2520 labels: []const u8, 2521 description: []const u8, 2522 } = null, 2523 }, 2524 patch: struct { 2525 failure: PatchFailure, 2526 fields: ?struct { 2527 title: []const u8, 2528 labels: []const u8, 2529 description: []const u8, 2530 target_branch: []const u8, 2531 source_branch: []const u8 = "", 2532 source_kind: Repo.Patches.Source.Kind = .fork, 2533 } = null, 2534 }, 2535 discussion: struct { 2536 failure: ThreadFailure, 2537 fields: ?struct { 2538 title: []const u8, 2539 labels: []const u8, 2540 description: []const u8, 2541 } = null, 2542 }, 2543 }; 2544 2545 const Self = @This(); 2546 2547 // serializable data sent down to web client 2548 pub const Data = struct { 2549 // the server's reference time for relative timestamps on this page. 2550 now: i64 = 0, 2551 user_id: ?[]const u8 = null, 2552 // the logged-in user's name and email, for the views that show them 2553 user_name: ?[]const u8 = null, 2554 user_email: ?[]const u8 = null, 2555 // the logged-in user's unread notifications, refreshed on each page build 2556 unread_notifs: u64 = 0, 2557 // a failed form retained long enough to render it for correction 2558 form_feedback: ?FormFeedback = null, 2559 // a transient local event-sync error 2560 sync_failure: ?[]const u8 = null, 2561 // a failed terminal undo retained on the current tab 2562 undo_failure: ?[]const u8 = null, 2563 current_page: RoutablePage = .default, 2564 // the selected ANSI art. the server serializes one image for WASM so 2565 // the browser does not need the whole build-generated collection. 2566 ansi_art: []const u8 = "", 2567 // the bound git-service ports. absent in local mode, where remote urls 2568 // are not shown. 2569 git_http_port: ?u16 = null, 2570 git_ssh_port: ?u16 = null, 2571 git_ssh_prefix: []const u8 = "", 2572 // whether this session views a local repo or the server. unlike `local` 2573 // (the host-side filesystem source) this travels in the snapshot, so 2574 // the wasm side also parses elided link urls and hides the multi-user 2575 // chrome. 2576 host_kind: evt.HostKind = .server, 2577 }; 2578 2579 pub fn init( 2580 arena: *std.heap.ArenaAllocator, 2581 repo: *rp.Repo(.xit, evt.admin_repo_opts), 2582 data: Data, 2583 ) !Self { 2584 var session = Self{ 2585 .data = data, 2586 .arena = arena, 2587 // until a host swaps in a page-scoped arena (Nav does this), page 2588 // allocations land in the session arena. on the web path that's the 2589 // intended behavior, since the whole arena is per-request. 2590 .page_arena = arena, 2591 .admin_repo = repo, 2592 .haxy_moment = try evt.currentMoment(evt.admin_repo_opts, repo), 2593 }; 2594 try session.loadUser(); 2595 return session; 2596 } 2597 2598 // web views share the page's reference time; terminals use the live clock. 2599 pub fn nowSeconds(self: *const Self) !i64 { 2600 if (builtin.target.cpu.arch == .wasm32 or !self.is_terminal) return self.data.now; 2601 const io = self.io orelse return error.NoClock; 2602 return std.math.cast(i64, std.Io.Timestamp.now(io, .real).toSeconds()) orelse error.TimestampOutOfRange; 2603 } 2604 2605 // load the logged-in user's name and persisted preferences from the db 2606 pub fn loadUser(self: *Self) !void { 2607 const user_id = self.userId() orelse return; 2608 const moment = self.haxy_moment orelse return; 2609 const user = (try activeUser(moment, self.arena, user_id)) orelse return self.logOut(); 2610 self.data.user_name = user.event.name; 2611 self.data.user_email = user.event.email; 2612 } 2613 2614 // forget the logged-in user, on a logout or once their account is removed 2615 pub fn logOut(self: *Self) void { 2616 self.data.user_id = null; 2617 self.data.user_name = null; 2618 self.data.user_email = null; 2619 } 2620 2621 // log in as the account the session's ssh key last logged into with a 2622 // password, while that account still holds the key 2623 pub fn logInWithSshKey(self: *Self) !void { 2624 const key = self.ssh_key orelse return; 2625 const admin_repo = self.admin_repo orelse return; 2626 const moment = self.haxy_moment orelse return; 2627 const fingerprint = evt.User.fingerprintOfKey(key) orelse return; 2628 const user_id = (try evt.readLoginKeyUser(try admin_repo.core.latestMoment(), &fingerprint)) orelse return; 2629 if (!try evt.User.holdsKey(evt.AdminDB, evt.admin_repo_opts.hash, moment, self.arena, &user_id, &fingerprint)) return; 2630 self.data.user_id = try self.arena.allocator().dupe(u8, &user_id); 2631 try self.loadUser(); 2632 } 2633 2634 // add the session's ssh key to the user who just logged in with a 2635 // password, which proves they hold it, and let it log them in from now on 2636 pub fn rememberSshKey(self: *Self) !void { 2637 if (builtin.target.cpu.arch == .wasm32) return; 2638 const key = self.ssh_key orelse return; 2639 const io = self.io orelse return; 2640 const users_dir = self.users_dir orelse return; 2641 const admin_repo = self.admin_repo orelse return; 2642 const user_id = self.userId() orelse return; 2643 const fingerprint = evt.User.fingerprintOfKey(key) orelse return; 2644 const allocator = self.arena.child_allocator; 2645 2646 const day = (std.time.epoch.EpochSeconds{ .secs = @intCast(std.Io.Timestamp.now(io, .real).toSeconds()) }).getEpochDay().calculateYearDay(); 2647 const month = day.calculateMonthDay(); 2648 const line = try std.fmt.allocPrint(allocator, "{s} added {d:0>4}-{d:0>2}-{d:0>2} via login", .{ key, day.year, month.month.numeric(), month.day_index + 1 }); 2649 defer allocator.free(line); 2650 evt.addUserKey(io, allocator, users_dir, admin_repo, &user_id, line) catch |err| switch (err) { 2651 // it's already there, or there's no room for it 2652 error.KeyAdded, error.TooManyKeys => {}, 2653 else => return err, 2654 }; 2655 try evt.setLoginKey(io, admin_repo, &fingerprint, &user_id, .trust); 2656 self.haxy_moment = try evt.currentMoment(evt.admin_repo_opts, admin_repo); 2657 } 2658 2659 // stop the session's ssh key logging the user in, on a logout 2660 pub fn forgetSshKey(self: *Self) !void { 2661 if (builtin.target.cpu.arch == .wasm32) return; 2662 const key = self.ssh_key orelse return; 2663 const io = self.io orelse return; 2664 const admin_repo = self.admin_repo orelse return; 2665 const user_id = self.userId() orelse return; 2666 const fingerprint = evt.User.fingerprintOfKey(key) orelse return; 2667 try evt.setLoginKey(io, admin_repo, &fingerprint, &user_id, .forget); 2668 } 2669 2670 pub fn userId(self: *const Self) ?[evt.event_id_size]u8 { 2671 const bytes = self.data.user_id orelse return null; 2672 if (bytes.len != evt.event_id_size) return null; 2673 var id: [evt.event_id_size]u8 = undefined; 2674 @memcpy(&id, bytes); 2675 return id; 2676 } 2677 2678 pub fn formFeedback(self: *const Self, comptime tag: std.meta.Tag(FormFeedback)) ?@FieldType(FormFeedback, @tagName(tag)) { 2679 const feedback = self.data.form_feedback orelse return null; 2680 if (std.meta.activeTag(feedback) != tag) return null; 2681 return @field(feedback, @tagName(tag)); 2682 } 2683 2684 pub fn clearFormFeedback(self: *Self) void { 2685 self.data.form_feedback = null; 2686 } 2687 2688 // the actor for a write to the repo `identity` names, or null when the 2689 // session may not write there with at least `min_role` or in the 2690 // `thread_kind` tab. local mode always writes. 2691 pub fn authorize(self: *Self, identity: []const u8, min_role: evt.Repo.Role, thread_kind: ?evt.EventKind) !?Actor { 2692 if (self.data.host_kind == .local) { 2693 const author = blk: { 2694 const src = self.local orelse break :blk local_author_fallback; 2695 const io = self.io orelse break :blk local_author_fallback; 2696 break :blk try localAuthor(src, io, self.page_arena.child_allocator, self.page_arena); 2697 }; 2698 return .{ .author = author, .role = .owner }; 2699 } 2700 const user_id = self.userId() orelse return null; 2701 // the session's moment predates this input, so read the roles afresh 2702 const admin_repo = self.admin_repo orelse return null; 2703 const io = self.io orelse return null; 2704 const users_dir = self.users_dir orelse return null; 2705 const moment = try evt.currentMoment(evt.admin_repo_opts, admin_repo); 2706 return switch (try authorizeUser(io, moment, self.page_arena, users_dir, user_id, identity, min_role, thread_kind)) { 2707 .actor => |actor| actor, 2708 .login_required, .repo_not_found, .forbidden => null, 2709 }; 2710 } 2711 2712 // where this session's event writes go. a server session always has a users dir and an admin repo 2713 pub fn eventHost(self: *const Self) evt.Host { 2714 return switch (self.data.host_kind) { 2715 .local => .local, 2716 .server => .{ .server = .{ 2717 .users_dir = self.users_dir orelse unreachable, 2718 .dirty_repos = self.dirty_repos, 2719 .admin_repo_path = (self.admin_repo orelse unreachable).core.work_path, 2720 } }, 2721 }; 2722 } 2723 2724 // reload the moment from the admin repo and log out a removed user 2725 pub fn reloadMoment(self: *Self, allocator: std.mem.Allocator, repo: *rp.Repo(.xit, evt.admin_repo_opts)) !void { 2726 const moment = try evt.currentMoment(evt.admin_repo_opts, repo); 2727 self.haxy_moment = moment; 2728 2729 const user_id = self.userId() orelse return; 2730 var arena = std.heap.ArenaAllocator.init(allocator); 2731 defer arena.deinit(); 2732 if (try activeUser(moment, &arena, user_id) == null) self.logOut(); 2733 } 2734 2735 // request a forward navigation to `route`; the host consumes next_page 2736 // (Nav.sync on the terminal, the wasm tick on the web). 2737 pub fn navigate(self: *Session, route: RoutablePage) !void { 2738 self.clearFormFeedback(); 2739 self.next_page = route; 2740 } 2741 }; 2742 2743 pub fn run(io: std.Io, allocator: std.mem.Allocator, session: *Session, repo_maybe: ?*rp.Repo(.xit, evt.admin_repo_opts)) !void { 2744 var nav = try Nav.init(allocator, session); 2745 defer nav.deinit(allocator); 2746 2747 var terminal = try term.Terminal.init(io, allocator); 2748 terminal.render_state.no_color = !session.color; 2749 var terminal_live = true; 2750 defer if (terminal_live) terminal.deinit(io); 2751 2752 // set term as active so it will be properly cooked 2753 // when a panic/segfault happens 2754 term.setActive(&terminal); 2755 defer term.setActive(null); 2756 2757 try terminal.queryBackground(); 2758 2759 while (!terminal.shouldQuit()) { 2760 const grid_changed = try terminal.render(&nav.root); 2761 2762 // process any inputs. 2763 // 2764 // if the grid didn't change, then first do a blocking 2765 // read, so the thread will sleep until further input. 2766 // after that, all remaining reads are non-blocking so 2767 // we can process the rest of the queued inputs. 2768 // 2769 // if the grid *did* change, then only do non-blocking 2770 // reads. we do not want to sleep the thread because 2771 // there may be an animation that requires more looping. 2772 var blocking = !grid_changed; 2773 while (try terminal.readKey(io, blocking)) |key| { 2774 blocking = false; 2775 try inputKey(allocator, &nav.root, key, session); 2776 } 2777 2778 if (session.host_request) |request| { 2779 session.host_request = null; 2780 switch (request) { 2781 .show_copyable_text => |copyable_text| { 2782 term.setActive(null); 2783 terminal.deinit(io); 2784 terminal_live = false; 2785 showCopyableText(io, copyable_text) catch |show_err| { 2786 terminal = try term.Terminal.init(io, allocator); 2787 terminal_live = true; 2788 term.setActive(&terminal); 2789 return show_err; 2790 }; 2791 terminal = try term.Terminal.init(io, allocator); 2792 terminal.render_state.no_color = !session.color; 2793 terminal_live = true; 2794 term.setActive(&terminal); 2795 }, 2796 .undo => |target| Repo.Undo.handleRequest(allocator, session, target), 2797 .sync_events => { 2798 try nav.root.build(allocator, .{ 2799 .min_size = .{ .width = null, .height = null }, 2800 .max_size = .{ .width = terminal.size.width, .height = terminal.size.height }, 2801 }, nav.root.getFocus()); 2802 _ = try terminal.render(&nav.root); 2803 try Repo.Events.performSync(allocator, session); 2804 }, 2805 } 2806 } 2807 2808 // reload so the next navigation builds its page from the current moment 2809 if (repo_maybe) |repo| try session.reloadMoment(allocator, repo); 2810 2811 // reconcile navigation: forward to a new page, or back on escape. 2812 // the terminal background can change at any time, so recheck it on each page 2813 if (try nav.sync(allocator, session)) try terminal.queryBackground(); 2814 2815 // the quit button (on the quit tab) asks the host to tear down. 2816 if (session.quit_requested) terminal.requestQuit(); 2817 2818 try nav.root.build(allocator, .{ 2819 .min_size = .{ .width = null, .height = null }, 2820 .max_size = .{ .width = terminal.size.width, .height = terminal.size.height }, 2821 }, nav.root.getFocus()); 2822 } 2823 } 2824 2825 fn showCopyableText(io: std.Io, copyable_text: []const u8) !void { 2826 const tty: ?std.Io.File = if (builtin.target.os.tag == .windows) 2827 null 2828 else 2829 try std.Io.Dir.cwd().openFile(io, "/dev/tty", .{ .mode = .read_write }); 2830 defer if (tty) |file| file.close(io); 2831 2832 const input = if (tty) |file| file else std.Io.File.stdin(); 2833 const output = if (tty) |file| file else std.Io.File.stdout(); 2834 2835 var write_buf: [1024]u8 = undefined; 2836 var writer = output.writer(io, &write_buf); 2837 try writer.interface.print("\x1b[2J\x1b[Hcopy the following text and then press enter to go back:\r\n\r\n{s}\r\n", .{copyable_text}); 2838 try writer.interface.flush(); 2839 2840 var read_buf: [1]u8 = undefined; 2841 var reader = input.reader(io, &read_buf); 2842 while (true) { 2843 const byte = try reader.interface.takeByte(); 2844 if (byte == '\r' or byte == '\n') return; 2845 } 2846 } 2847 2848 pub fn inputKey(allocator: std.mem.Allocator, root: *Widget, key: Key, session: *Session) !void { 2849 const root_focus = root.getFocus(); 2850 const focused_before = root_focus.grandchild_id; 2851 var activate_in_page_link = false; 2852 switch (key) { 2853 // request a navigation pop; the host's Nav.sync goes back a page, or 2854 // quits when there's no history left. 2855 .escape => { 2856 session.back = .requested; 2857 return; 2858 }, 2859 .ctrl => |letter| switch (letter) { 2860 'r' => { 2861 session.refresh_requested = true; 2862 return; 2863 }, 2864 else => try root.input(allocator, key, root_focus), 2865 }, 2866 .enter => { 2867 if (root_focus.grandchild_id) |gid| { 2868 // follow a cross-page link 2869 if (crossPageLink(root_focus, gid, session.data)) |route| { 2870 return session.navigate(route); 2871 } 2872 if (rawLink(root_focus, gid)) |url| return requestRawLink(session, url); 2873 } 2874 activate_in_page_link = true; 2875 try root.input(allocator, key, root_focus); 2876 }, 2877 .mouse => |mouse| { 2878 if (mouse.button == .left) { 2879 if (cellPageLink(root, mouse.x, mouse.y, session.data)) |route| return session.navigate(route); 2880 if (root_focus.hitTest(mouse.x, mouse.y)) |hit| { 2881 const focus_id = hit.id; 2882 if (hit.kind == .background) { 2883 root_focus.setFocus(focus_id); 2884 return; 2885 } 2886 if (widget.isBackButton((root_focus.children.get(focus_id) orelse return).focus)) { 2887 if (session.back == .available) session.back = .requested; 2888 return; 2889 } 2890 // follow a cross-page link 2891 if (crossPageLink(root_focus, focus_id, session.data)) |route| { 2892 return session.navigate(route); 2893 } 2894 if (currentPageLink(root_focus, focus_id, session.data)) return; 2895 if (rawLink(root_focus, focus_id)) |url| { 2896 if (!mouse.ctrl) try requestRawLink(session, url); 2897 return; 2898 } 2899 root_focus.setFocus(focus_id); 2900 activate_in_page_link = true; 2901 } 2902 // forward the press into the widget tree so buttons (and any 2903 // future click-aware widgets) can react. widgets that don't 2904 // care about presses ignore it. 2905 try root.input(allocator, key, root_focus); 2906 } else { 2907 try root.input(allocator, key, root_focus); 2908 } 2909 }, 2910 .event => |event| switch (event) { 2911 .background => |rgb| session.terminal_background = rgb, 2912 .resize => try root.input(allocator, key, root_focus), 2913 }, 2914 // a scroll tick goes to the pane under the pointer, focusing it first. 2915 // from outside any pane (the tabs, a sub-header) or from a text input 2916 // it moves like the arrows instead, so scrolling up from a search box 2917 // reaches the header and stops there. 2918 .scroll_up, .scroll_down => |position| { 2919 if (focusedPane(root_focus)) |pane| if (scrollUnder(root_focus, position)) |under| { 2920 if (under != pane) root_focus.setFocus(under); 2921 }; 2922 try root.input(allocator, key, root_focus); 2923 }, 2924 else => try root.input(allocator, key, root_focus), 2925 } 2926 2927 const focused_after = root_focus.grandchild_id; 2928 if (!activate_in_page_link and focused_before == focused_after) return; 2929 const focus_id = focused_after orelse return; 2930 if (inPageLink(root_focus, focus_id, session.data)) |route| session.data.current_page = route; 2931 } 2932 2933 // the innermost vertically scrolling widget laid out under `position` 2934 fn scrollUnder(root_focus: *const Focus, position: xitui.input.Position) ?usize { 2935 var found: ?usize = null; 2936 var found_area: usize = std.math.maxInt(usize); 2937 var iter = root_focus.children.iterator(); 2938 while (iter.next()) |entry| { 2939 const info = entry.value_ptr.focus.scroll orelse continue; 2940 if (info.direction == .horiz) continue; 2941 const r = entry.value_ptr.rect; 2942 if (position.x < r.x or position.y < r.y or position.x >= r.x + r.size.width or position.y >= r.y + r.size.height) continue; 2943 // a nested scroll lies within its parent, so the smallest is innermost 2944 const area = r.size.width * r.size.height; 2945 if (area < found_area) { 2946 found = entry.key_ptr.*; 2947 found_area = area; 2948 } 2949 } 2950 return found; 2951 } 2952 2953 // the innermost vertically scrolling widget the focus lies in, unless the 2954 // focused widget is a text input 2955 fn focusedPane(root_focus: *const Focus) ?usize { 2956 var current = root_focus.grandchild_id orelse return null; 2957 switch ((root_focus.children.get(current) orelse return null).focus.kind) { 2958 .text_input, .text_input_password, .text_area => return null, 2959 else => {}, 2960 } 2961 while (root_focus.children.get(current)) |child| { 2962 if (child.focus.scroll) |info| if (info.direction != .horiz) return current; 2963 current = child.parent_id; 2964 } 2965 return null; 2966 } 2967 2968 // a link to bytes the server serves directly rather than to a page route, so 2969 // it never resolves to a RoutablePage 2970 pub const raw_link_prefix = "ax:"; 2971 2972 // a route already loaded into the current page; focusing it changes the url 2973 // without rebuilding the page 2974 pub const in_page_link_prefix = "ai:"; 2975 2976 // an in-page tab's base route, or the exact current route when it is selected 2977 pub fn inPageTabLink(session: *Session, route: RoutablePage, selected: bool) ![]const u8 { 2978 const target = if (selected) session.data.current_page else route; 2979 return session.page_arena.allocator().print("{s}{s}", .{ in_page_link_prefix, try target.toUrl(session.page_arena) }); 2980 } 2981 2982 // a button the web renderer covers with a file picker that posts the chosen 2983 // file to the url after the prefix 2984 pub const file_input_prefix = "file:"; 2985 2986 // a submit button that overrides its enclosing form's action 2987 pub const submit_action_prefix = "submit:"; 2988 2989 // one option of a radio field, as `radio:<name>=<value>` 2990 pub const radio_prefix = "radio:"; 2991 2992 // the url an `ax:` link points at, or null for anything else. a host that 2993 // suppresses the anchor's own navigation needs this to follow the link. 2994 pub fn rawLink(root_focus: *Focus, focus_id: usize) ?[]const u8 { 2995 const child = root_focus.children.get(focus_id) orelse return null; 2996 const custom = switch (child.focus.kind) { 2997 .custom => |c| c, 2998 else => return null, 2999 }; 3000 if (!std.mem.startsWith(u8, custom, raw_link_prefix)) return null; 3001 return custom[raw_link_prefix.len..]; 3002 } 3003 3004 fn requestRawLink(session: *Session, url: []const u8) !void { 3005 session.host_request = .{ .show_copyable_text = try terminalWebUrl(session.page_arena.allocator(), session, url) }; 3006 } 3007 3008 pub fn terminalWebUrl(allocator: std.mem.Allocator, session: *const Session, path: []const u8) ![]const u8 { 3009 // an absolute url is already complete 3010 if (!std.mem.startsWith(u8, path, "/")) return path; 3011 return if (session.web_port) |port| 3012 try allocator.print("http://localhost:{d}{s}", .{ port, path }) 3013 else 3014 path; 3015 } 3016 3017 // if the focus target at focus_id is an `a:` link that should navigate (a 3018 // different parent page, or a different files directory within the repo page), 3019 // return its route; otherwise null. lets a host turn a click / enter on such a 3020 // link into a navigation rather than a focus change. 3021 pub fn crossPageLink(root_focus: *Focus, focus_id: usize, data: Session.Data) ?RoutablePage { 3022 const current = data.current_page; 3023 const route = pageLink(root_focus, focus_id, data, "a:") orelse return null; 3024 // a link to a different parent page always navigates; within a page, a 3025 // files-directory / commits-page / list-window change navigates while tab 3026 // links stay in-page. 3027 if (route.parent() != current.parent() or RoutablePage.repoPageChanged(route, current) or RoutablePage.forkPageChanged(route, current) or RoutablePage.homePageChanged(route, current) or RoutablePage.userPageChanged(route, current)) return route; 3028 return null; 3029 } 3030 3031 // the page the clicked cell links to, from a link inside a text box's content 3032 fn cellPageLink(root: *Widget, x: usize, y: usize, data: Session.Data) ?RoutablePage { 3033 const grid = root.getGrid() orelse return null; 3034 const cell = grid.cell(x, y) catch return null; 3035 const link = cell.link orelse return null; 3036 if (link.len == 0 or link[0] != '/') return null; 3037 return if (data.host_kind == .local) RoutablePage.fromUrlLocal(link) else RoutablePage.fromUrl(link); 3038 } 3039 3040 // whether the `a:` focus target links to the page already shown, which a click 3041 // leaves alone rather than focusing 3042 pub fn currentPageLink(root_focus: *Focus, focus_id: usize, data: Session.Data) bool { 3043 const route = pageLink(root_focus, focus_id, data, "a:") orelse return false; 3044 return route.eql(data.current_page); 3045 } 3046 3047 // the route carried by an `ai:` focus target, or null for any other target 3048 pub fn inPageLink(root_focus: *Focus, focus_id: usize, data: Session.Data) ?RoutablePage { 3049 return pageLink(root_focus, focus_id, data, in_page_link_prefix); 3050 } 3051 3052 fn pageLink(root_focus: *Focus, focus_id: usize, data: Session.Data, prefix: []const u8) ?RoutablePage { 3053 const child = root_focus.children.get(focus_id) orelse return null; 3054 const custom = switch (child.focus.kind) { 3055 .custom => |c| c, 3056 else => return null, 3057 }; 3058 if (!std.mem.startsWith(u8, custom, prefix)) return null; 3059 const path = custom[prefix.len..]; 3060 // local sessions build (and parse) links with the repo identity elided 3061 return if (data.host_kind == .local) RoutablePage.fromUrlLocal(path) else RoutablePage.fromUrl(path); 3062 } 3063 3064 // an assignee as the detail view shows it: the stored email, and the name of 3065 // the user holding it on this instance when there is one 3066 pub const Assignee = struct { 3067 email: []const u8, 3068 name: ?[]const u8 = null, 3069 3070 pub fn display(self: Assignee) []const u8 { 3071 return self.name orelse self.email; 3072 } 3073 }; 3074 3075 // a display author, resolved against the admin db at read time 3076 pub const Author = union(enum) { 3077 unknown, 3078 email: []const u8, // an email no user matches 3079 user_name: []const u8, 3080 3081 // the commit author line's email, resolved like initFromEmail 3082 pub fn init( 3083 haxy_moment: ?evt.AdminDB.HashMap(.read_only), 3084 arena: *std.heap.ArenaAllocator, 3085 author_line: []const u8, 3086 ) !Author { 3087 return initFromEmail(haxy_moment, arena, evt.authorEmail(author_line)); 3088 } 3089 3090 // from a record's stored author email: it resolves to the user currently 3091 // holding it, else stays the email. the result is allocated in `arena`, 3092 // so the email may borrow transient memory. 3093 pub fn initFromEmail( 3094 haxy_moment: ?evt.AdminDB.HashMap(.read_only), 3095 arena: *std.heap.ArenaAllocator, 3096 author_email: ?[]const u8, 3097 ) !Author { 3098 const email = author_email orelse return .unknown; 3099 if (haxy_moment) |moment| { 3100 if (try evt.User.readByEmail(evt.AdminDB, evt.admin_repo_opts.hash, moment, arena, email)) |user| { 3101 return .{ .user_name = user.name }; 3102 } 3103 } 3104 return .{ .email = try arena.allocator().dupe(u8, email) }; 3105 } 3106 }; 3107 3108 // the "a:" link to user `name`'s page. 3109 pub fn userLink(page_arena: *std.heap.ArenaAllocator, name: []const u8) ![]const u8 { 3110 const parsed = RoutablePage.Array(evt.User.name_max_len).from(name) orelse return error.RouteTooLong; 3111 const url = try (RoutablePage{ .user_repos = .{ .name = parsed } }).toUrl(page_arena); 3112 return page_arena.allocator().print("a:{s}", .{url}); 3113 } 3114 3115 // a focusable " author " box showing `author`, linking to their user page 3116 // when it is a known user 3117 pub fn authorBox(allocator: std.mem.Allocator, page_arena: *std.heap.ArenaAllocator, author: Author) !wgt.TextBox { 3118 const text = switch (author) { 3119 .unknown => "", 3120 .email, .user_name => |t| t, 3121 }; 3122 var tb = try wgt.TextBox.init(allocator, text, .{ .border = .single, .round_corners = true, .wrap_kind = .none, .top_label = .{ .text = " author " } }); 3123 errdefer tb.deinit(allocator); 3124 tb.getFocus().mode = .all; 3125 switch (author) { 3126 .user_name => |name| tb.getFocus().kind = .{ .custom = try userLink(page_arena, name) }, 3127 else => {}, 3128 } 3129 return tb; 3130 } 3131 3132 // native-TUI navigation 3133 pub const Nav = struct { 3134 root: Widget, 3135 // backs the current page (its Page data and the page-scoped strings its 3136 // widgets build). owned here, swapped on each navigation. session.page_arena 3137 // tracks whichever of these is current so widgets allocate into it. 3138 arena: *std.heap.ArenaAllocator, 3139 // the login the current root was built for 3140 user_id: ?[evt.event_id_size]u8, 3141 history: std.ArrayList(Entry), 3142 3143 // each retained page keeps its own arena, freed when the page leaves the 3144 // stack (popped on back, evicted at cap, or on deinit). this is what keeps a 3145 // long-lived session from accumulating every page it ever visited. 3146 const Entry = struct { root: Widget, route: RoutablePage, arena: *std.heap.ArenaAllocator, user_id: ?[evt.event_id_size]u8 }; 3147 3148 // cap on retained back-history so the chain can't grow memory without bound 3149 const max_history: usize = 16; 3150 3151 pub fn init(allocator: std.mem.Allocator, session: *Session) !Nav { 3152 session.back = .unavailable; 3153 const arena = try allocator.create(std.heap.ArenaAllocator); 3154 arena.* = std.heap.ArenaAllocator.init(allocator); 3155 errdefer { 3156 arena.deinit(); 3157 allocator.destroy(arena); 3158 } 3159 3160 return .{ 3161 .root = try initCurrentRoot(allocator, arena, session), 3162 .arena = arena, 3163 .user_id = session.userId(), 3164 .history = .empty, 3165 }; 3166 } 3167 3168 pub fn deinit(self: *Nav, allocator: std.mem.Allocator) void { 3169 self.root.deinit(allocator); 3170 freeArena(allocator, self.arena); 3171 for (self.history.items) |*entry| { 3172 entry.root.deinit(allocator); 3173 freeArena(allocator, entry.arena); 3174 } 3175 self.history.deinit(allocator); 3176 } 3177 3178 // rebuild the current page in place from a current moment 3179 fn rebuild(self: *Nav, allocator: std.mem.Allocator, session: *Session) !void { 3180 if (session.haxy_moment == null and session.local == null) return; 3181 const arena = try allocator.create(std.heap.ArenaAllocator); 3182 arena.* = std.heap.ArenaAllocator.init(allocator); 3183 errdefer freeArena(allocator, arena); 3184 3185 const new_root = try initCurrentRoot(allocator, arena, session); 3186 3187 self.root.deinit(allocator); 3188 freeArena(allocator, self.arena); 3189 self.root = new_root; 3190 self.arena = arena; 3191 self.user_id = session.userId(); 3192 } 3193 3194 // build the current page's root in `arena`, or the not-found page's when 3195 // the route names nothing 3196 fn initCurrentRoot(allocator: std.mem.Allocator, arena: *std.heap.ArenaAllocator, session: *Session) !Widget { 3197 session.page_arena = arena; 3198 const page = try arena.allocator().create(Page); 3199 page.* = Page.init(arena, session, session.data.current_page) catch |err| switch (err) { 3200 error.NotFound => blk: { 3201 session.data.current_page = .not_found; 3202 break :blk .not_found; 3203 }, 3204 else => |e| return e, 3205 }; 3206 return initRoot(allocator, page, session); 3207 } 3208 3209 fn freeArena(allocator: std.mem.Allocator, arena: *std.heap.ArenaAllocator) void { 3210 arena.deinit(); 3211 allocator.destroy(arena); 3212 } 3213 3214 // reconcile the displayed root with the session's navigation state. a 3215 // forward nav (current_page moved to a different parent page) pushes the 3216 // current root and builds the new page in a fresh arena; a back request 3217 // frees the current page and restores the previous one. when escape is 3218 // pressed with no history left we switch to the quit tab instead of quitting. 3219 // returns whether the root was replaced. 3220 pub fn sync(self: *Nav, allocator: std.mem.Allocator, session: *Session) !bool { 3221 // refresh: rebuild the current page in place from a current moment 3222 if (session.refresh_requested) { 3223 session.refresh_requested = false; 3224 try self.rebuild(allocator, session); 3225 return true; 3226 } 3227 3228 if (session.back == .requested) { 3229 if (self.history.pop()) |entry| { 3230 session.back = if (self.history.items.len == 0) .unavailable else .available; 3231 self.root.deinit(allocator); 3232 freeArena(allocator, self.arena); 3233 self.root = entry.root; 3234 self.arena = entry.arena; 3235 self.user_id = entry.user_id; 3236 session.page_arena = entry.arena; 3237 session.data.current_page = entry.route; 3238 // a page built under another login is rebuilt rather than restored 3239 if (!std.meta.eql(entry.user_id, session.userId())) { 3240 try self.rebuild(allocator, session); 3241 return true; 3242 } 3243 chooseAnsiArtForNavigation(session); 3244 return true; 3245 } 3246 session.back = .unavailable; 3247 // nothing to go back to; switch to the quit confirmation 3248 const root_focus = self.root.getFocus(); 3249 var iter = root_focus.children.iterator(); 3250 while (iter.next()) |entry| { 3251 switch (entry.value_ptr.focus.kind) { 3252 .custom => |custom| if (std.mem.eql(u8, custom, Quit.tab_kind)) { 3253 root_focus.setFocus(entry.key_ptr.*); 3254 // a Stack only builds its selected child, so the quit 3255 // button isn't in the focus tree until a build runs with 3256 // the quit tab selected. build once, then send a 3257 // synthetic arrow_down to drop focus from the tab onto 3258 // the button so it's ready to confirm. 3259 try self.root.build(allocator, .{ 3260 .min_size = .{ .width = null, .height = 40 }, 3261 .max_size = .{ .width = 80, .height = null }, 3262 }, root_focus); 3263 try self.root.input(allocator, .arrow_down, root_focus); 3264 break; 3265 }, 3266 else => {}, 3267 } 3268 } 3269 return false; 3270 } 3271 3272 // forward navigation: navigate() set next_page to the page to move to (a 3273 // cross-page link or tab change crossing pages) 3274 if (session.next_page) |route| { 3275 session.next_page = null; 3276 if (session.haxy_moment == null and session.local == null) return false; 3277 const previous_route = session.data.current_page; 3278 // the page we navigated to becomes the current page 3279 session.data.current_page = route; 3280 session.back = .available; 3281 3282 const arena = try allocator.create(std.heap.ArenaAllocator); 3283 arena.* = std.heap.ArenaAllocator.init(allocator); 3284 errdefer freeArena(allocator, arena); 3285 3286 const new_root = try initCurrentRoot(allocator, arena, session); 3287 3288 try self.history.append(allocator, .{ .root = self.root, .route = previous_route, .arena = self.arena, .user_id = self.user_id }); 3289 // drop the oldest entry (freeing its widget tree and arena) once over cap 3290 if (self.history.items.len > max_history) { 3291 var oldest = self.history.orderedRemove(0); 3292 oldest.root.deinit(allocator); 3293 freeArena(allocator, oldest.arena); 3294 } 3295 self.root = new_root; 3296 self.arena = arena; 3297 self.user_id = session.userId(); 3298 return true; 3299 } 3300 return false; 3301 } 3302 }; 3303 3304 pub fn initRoot(allocator: std.mem.Allocator, page: *const Page, session: *Session) !Widget { 3305 const page_widget: Widget = switch (page.*) { 3306 .home => |*p| .{ .home = try .init(allocator, p, session) }, 3307 .user => |*p| .{ .user = try .init(allocator, p, session) }, 3308 .repo => |*p| .{ .repo = try .init(allocator, p, session) }, 3309 .fork => |*p| .{ .fork = try .init(allocator, p, session) }, 3310 .not_found => .{ .not_found = try .init(allocator, session) }, 3311 }; 3312 3313 chooseAnsiArtForNavigation(session); 3314 3315 // on the TUI/SSH, the page sits above a one-row footer showing its web 3316 // url, when there's a web UI to point at 3317 var root = if (session.is_terminal) blk: { 3318 var box = try wgt.Box(Widget).init(allocator, .{ .border = null, .direction = .vert }); 3319 errdefer box.deinit(allocator); 3320 const bg_id = bg_blk: { 3321 var background = try widget.AnsiBackground.init(allocator, page_widget, session); 3322 errdefer background.deinit(allocator); 3323 const id = background.getFocus().id; 3324 try box.children.put(allocator, id, .{ .widget = .{ .background = background }, .rect = null, .min_size = null }); 3325 break :bg_blk id; 3326 }; 3327 if (session.web_port != null) { 3328 var footer = try widget.Footer.init(allocator, session); 3329 errdefer footer.deinit(allocator); 3330 try box.children.put(allocator, footer.getFocus().id, .{ .widget = .{ .footer = footer }, .rect = null, .min_size = .{ .width = null, .height = 1 } }); 3331 } 3332 box.getFocus().child_id = bg_id; 3333 break :blk Widget{ .box = box }; 3334 } else Widget{ .background = try widget.AnsiBackground.init(allocator, page_widget, session) }; 3335 errdefer root.deinit(allocator); 3336 3337 // input-owning views build their TextInputs in init, so reset their 3338 // registrations here 3339 session.text_inputs.clearRetainingCapacity(); 3340 3341 try root.build(allocator, .{ 3342 .min_size = .{ .width = null, .height = 40 }, 3343 .max_size = .{ .width = 100, .height = null }, 3344 }, root.getFocus()); 3345 3346 return root; 3347 } 3348 3349 // native and server-side sessions choose from the embedded collection. WASM 3350 // keeps the art serialized by the server instead. 3351 fn chooseAnsiArtForNavigation(session: *Session) void { 3352 if (comptime ansi_art.art.len == 0) return; 3353 if (session.data.current_page == .not_found) { 3354 session.data.ansi_art = ansi_art.not_found; 3355 return; 3356 } 3357 const io = session.io orelse return; 3358 var random: usize = undefined; 3359 io.random(std.mem.asBytes(&random)); 3360 session.data.ansi_art = ansi_art.art[random % ansi_art.art.len]; 3361 } 3362