╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ Threads.zig ──────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1961 defer allocator.free(tags); 1962 const description = try description_input.text(allocator); 1963 defer allocator.free(description); 1964 1965 if (!Event.fieldsValid(title, tags)) return; 1966 1967 var id_bytes: [evt.event_id_size]u8 = undefined; 1968 io.random(&id_bytes); 1969 const event_id_hex = std.fmt.bytesToHex(id_bytes, .lower); 1970 1971 const event = evt.EventWithId{ 1972 .id = event_id_hex, 1973 .timestamp = @intCast(std.Io.Timestamp.now(io, .real).toSeconds()), 1974 .author = author, 1975 .event = @unionInit(evt.Event, @tagName(kind), Event{ 1976 .title = title, 1977 .description = description, 1978 .tags = tags, 1979 }), 1980 }; 1981 1982 switch (src.repo_kind) { 1983 inline else => |repo_kind| { 1984 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); 1985 defer any_repo.deinit(io, allocator); 1986 switch (any_repo) { 1987 inline else => |*repo| try evt.consume(repo_kind, repo.self_repo_opts, io, allocator, repo, evt.events_ref, &.{event}), 1988 } 1989 }, 1990 } 1991 1992 // wipe the form so a return visit starts fresh 1993 title_input.clear(allocator); 1994 tags_input.clear(allocator); 1995 description_input.clear(allocator); 1996 1997 const route = listRoute(self.data.identity, @enumFromInt(0), "", &event_id_hex) orelse return; 1998 try self.session.navigate(route); 1999 } 2000 2001 // re-emit the selected issue's event with the form's content (status 2002 // preserved), then reload the issue's page. this is the terminal path; 2003 // the web posts the form to the edit route. 2004 fn submitEditedThread(self: *This, allocator: std.mem.Allocator) !void { 2005 if (comptime wasm) return; 2006 const io = self.session.io orelse return; 2007 const src = self.data.repo_source orelse return; 2008 const entry = self.data.selectedThread() orelse return; 2009 const author = (try self.session.eventAuthor()) orelse return; 2010 2011 const form = self.threadForm() orelse return; 2012 const title_input = &form.children.values()[title_field_index].widget.text_input; 2013 const tags_input = &form.children.values()[tags_field_index].widget.text_input; 2014 const description_input = &form.children.values()[description_field_index].widget.text_input; 2015 2016 const title = try title_input.text(allocator); 2017 defer allocator.free(title); 2018 const tags = try tags_input.text(allocator); 2019 defer allocator.free(tags); 2020 const description = try description_input.text(allocator); 2021 defer allocator.free(description); 2022 2023 if (!Event.fieldsValid(title, tags)) return; 2024 2025 var id_bytes: [evt.event_id_size]u8 = undefined; 2026 _ = try std.fmt.hexToBytes(&id_bytes, entry.id[0 .. evt.event_id_size * 2]); 2027 2028 switch (src.repo_kind) { 2029 inline else => |repo_kind| { 2030 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); 2031 defer any_repo.deinit(io, allocator); 2032 switch (any_repo) { 2033 inline else => |*repo| if (has_status) 2034 try Event.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &id_bytes, .{ .fields = .{ 2035 .title = title, 2036 .tags = tags, 2037 .description = description, 2038 } }, author) 2039 else 2040 try Event.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &id_bytes, title, tags, description, author), 2041 } 2042 }, 2043 } 2044 2045 const route = listRoute(self.data.identity, entryStatus(entry.*), "", entry.id) orelse return; 2046 try self.session.navigate(route); 2047 } 2048 2049 fn submitRemove(self: *This, allocator: std.mem.Allocator) !void { 2050 const comment = self.data.comment_id.len != 0; 2051 const event_id = if (comment) self.data.comment_id else self.data.selected_id; 2052 var id: [evt.event_id_size]u8 = undefined; 2053 _ = std.fmt.hexToBytes(&id, event_id) catch return; 2054 try self.removeEvent(allocator, if (comment) .comment else kind, id); 2055 } 2056 2057 fn removeEvent(self: *This, allocator: std.mem.Allocator, event_kind: evt.EventKind, id: [evt.event_id_size]u8) !void { 2058 if (comptime wasm) return; 2059 const io = self.session.io orelse return; 2060 const src = self.data.repo_source orelse return; 2061 const author = (try self.session.eventAuthor()) orelse return; 2062 2063 switch (src.repo_kind) { 2064 inline else => |repo_kind| { 2065 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); 2066 defer any_repo.deinit(io, allocator); 2067 switch (any_repo) { 2068 inline else => |*repo| try evt.remove(repo_kind, repo.self_repo_opts, io, allocator, repo, &id, event_kind, author), 2069 } 2070 }, 2071 } 2072 2073 const id_hex = std.fmt.bytesToHex(id, .lower); 2074 const route = ui.RoutablePage.repoEventsRoute(self.data.identity, .removed, event_kind, &id_hex) orelse return; 2075 try self.session.navigate(route); 2076 } 2077 2078 // flip the shown issue's status by re-emitting its event, then reload the 2079 // page rooted at the issue so the view reflects the change. this is the 2080 // terminal path; the web posts the button's form to the status route. 2081 fn toggleStatus(self: *This, allocator: std.mem.Allocator, index: usize) !void { 2082 if (comptime !has_status) return; 2083 if (comptime wasm) return; 2084 const io = self.session.io orelse return; 2085 const src = self.data.repo_source orelse return; 2086 const sel = self.detailed_index[index] orelse return; 2087 const entry = self.window(index).items[sel]; 2088 const author = (try self.session.eventAuthor()) orelse return; 2089 2090 const status: Status = switch (entryStatus(entry)) { 2091 .open => .closed, 2092 .closed => .open, 2093 }; 2094 2095 var id_bytes: [evt.event_id_size]u8 = undefined; 2096 _ = try std.fmt.hexToBytes(&id_bytes, entry.id[0 .. evt.event_id_size * 2]); 2097 2098 switch (src.repo_kind) { 2099 inline else => |repo_kind| { 2100 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); 2101 defer any_repo.deinit(io, allocator); 2102 switch (any_repo) { 2103 inline else => |*repo| try Event.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &id_bytes, .{ .status = status }, author), 2104 } 2105 }, 2106 } 2107 2108 const route = listRoute(self.data.identity, status, "", entry.id) orelse return; 2109 try self.session.navigate(route); 2110 } 2111 2112 // arrow keys move the tag selection; at the flow's edges focus crosses to 2113 // the neighboring widgets. 2114 fn tagsInput(self: *This, index: usize, key: Key, root_focus: *Focus) !void { 2115 const tf = self.tagFlow(index) orelse return; 2116 const cid = tf.focus.child_id orelse return; 2117 const cur = tf.indexOfFocusId(cid) orelse return; 2118 const count = tf.text_boxes.items.len; 2119 switch (key) { 2120 .arrow_left => if (cur > 0) self.focusTag(index, tf, root_focus, cur - 1) else try self.focusList(index, root_focus), 2121 .arrow_right => if (cur + 1 < count) self.focusTag(index, tf, root_focus, cur + 1), 2122 .arrow_up => if (tf.rowStep(cur, false)) |i| { 2123 self.focusTag(index, tf, root_focus, i); 2124 } else { 2125 _ = self.moveDetailVertical(index, root_focus, false); 2126 }, 2127 .arrow_down => if (tf.rowStep(cur, true)) |i| { 2128 self.focusTag(index, tf, root_focus, i); 2129 } else { 2130 _ = self.moveDetailVertical(index, root_focus, true); 2131 }, 2132 .home => self.focusTag(index, tf, root_focus, 0), 2133 .end => self.focusTag(index, tf, root_focus, count - 1), 2134 else => {}, 2135 } 2136 } 2137 2138 const title_child_index: usize = 0; 2139 const tags_child_index: usize = 2; 2140 2141 // the tool row's first child after the spacer that pushes it right 2142 const first_in_row_index: usize = 1; 2143 2144 fn tagFlow(self: *This, index: usize) ?*ui.TagFlow { 2145 const inner = self.detailInner(index); 2146 if (inner.children.count() <= tags_child_index) return null; 2147 return switch (inner.children.values()[tags_child_index].widget) { 2148 .tag_flow => |*tf| tf, 2149 else => null, 2150 }; 2151 } 2152 2153 fn tagsFocused(self: *This, index: usize) bool { 2154 const inner = self.detailInner(index); 2155 const cid = inner.getFocus().child_id orelse return false; 2156 return inner.children.getIndex(cid) == tags_child_index and self.tagFlow(index) != null; 2157 } 2158 2159 fn toolRowFocused(self: *This, index: usize) bool { 2160 const outer = self.detailOuter(index); 2161 const cid = outer.getFocus().child_id orelse return false; 2162 return outer.children.getIndex(cid) == tool_row_index; 2163 } 2164 2165 fn focusTag(self: *This, index: usize, tf: *ui.TagFlow, root_focus: *Focus, item: usize) void { 2166 root_focus.setFocus(tf.text_boxes.items[item].getFocus().id); 2167 // keep the tag visible on the terminal: its rect offset by the flow's 2168 // position in the pane. 2169 if (self.session.is_terminal and item < tf.rects.items.len) { 2170 if (self.detailInner(index).children.values()[tags_child_index].rect) |flow_rect| { 2171 var rect = tf.rects.items[item]; 2172 rect.x += flow_rect.x; 2173 rect.y += flow_rect.y; 2174 self.detailScroll(index).scrollToRect(rect); 2175 } 2176 } 2177 } 2178 2179 fn titleFocused(self: *This, index: usize) bool { 2180 const inner = self.detailInner(index); 2181 const cid = inner.getFocus().child_id orelse return false; 2182 return inner.children.getIndex(cid) == title_child_index; 2183 } 2184 2185 fn authorFocused(self: *This, index: usize) bool { 2186 const inner = self.detailInner(index); 2187 const cid = inner.getFocus().child_id orelse return false; 2188 return cid == self.author_id[index]; 2189 } 2190 2191 fn descriptionFocused(self: *This, index: usize) bool { 2192 const cid = self.detailInner(index).getFocus().child_id orelse return false; 2193 return cid == self.description_id[index]; 2194 } 2195 2196 // return to the tool row's last-focused button (the header when the 2197 // description page shows no row). 2198 fn focusToolRow(self: *This, index: usize, root_focus: *Focus) void { 2199 const cid = self.toolRow(index).getFocus().child_id orelse return self.focusHeader(root_focus); 2200 root_focus.setFocus(cid); 2201 } 2202 2203 // enter the detail pane. an empty pane (no issues) can't be entered. 2204 fn focusDetail(self: *This, index: usize, root_focus: *Focus) !void { 2205 if (self.detailInner(index).children.count() == 0) return; 2206 root_focus.setFocus(self.detailOuter(index).getFocus().id); 2207 } 2208 2209 // return to the list. 2210 fn focusList(self: *This, index: usize, root_focus: *Focus) !void { 2211 root_focus.setFocus(self.listScroll(index).getFocus().id); 2212 } 2213 2214 // cross to the header tabs above the stack. 2215 fn focusHeader(self: *This, root_focus: *Focus) void { 2216 root_focus.setFocus(self.box.children.keys()[header_index]); 2217 } 2218 2219 pub fn clearGrid(self: *This) void { 2220 self.box.clearGrid(); 2221 } 2222 2223 pub fn getGrid(self: This) ?Grid { 2224 return self.box.getGrid(); 2225 } 2226 2227 pub fn getFocus(self: *This) *Focus { 2228 return self.box.getFocus(); 2229 } 2230 2231 // for the parent's "scroll up at the top jumps to the header" check: at the 2232 // top only while the header tabs hold focus, so up from the split 2233 // crosses into the tabs first. 2234 pub fn getSelectedIndex(self: *This) ?usize { 2235 return if (self.headerActive()) 0 else 1; 2236 } 2237 }; 2238 } 2239