╭ Settings.zig ─────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1 const std = @import("std"); 2 const builtin = @import("builtin"); 3 const evt = @import("../../event.zig"); 4 const ui = @import("../../ui.zig"); 5 const inp = @import("../input.zig"); 6 const xit = @import("xit"); 7 const hash = xit.hash; 8 const xitui = xit.xitui; 9 const wgt = xitui.widget; 10 const layout = xitui.layout; 11 const Key = xitui.input.Key; 12 const Grid = xitui.grid.Grid; 13 const Focus = xitui.focus.Focus; 14 15 const Members = @import("./Members.zig"); 16 const Branches = @import("./Branches.zig"); 17 18 const wasm = builtin.target.cpu.arch == .wasm32; 19 20 pub const tab_label = "☼"; 21 22 // the repo's settings, which only its owner sees. the hash kind is fixed at 23 // creation, so it only shows. 24 identity: []const u8, 25 name: []const u8, 26 description: []const u8, 27 access: evt.Repo.Access, 28 discuss_role: ?evt.Repo.Role, 29 issue_role: ?evt.Repo.Role, 30 patch_role: ?evt.Repo.Role, 31 hash_kind: hash.HashKind, 32 // the sub tab the route names 33 view: enum { settings, members, branches }, 34 members: Members, 35 branches: Branches, 36 37 const Self = @This(); 38 39 // the tab role choices, in the order the form shows them. no owners-only 40 // choice, so anyone who can push may push events for every tab. 41 const role_choices = [_]?evt.Repo.Role{ null, .write, .read }; 42 43 const role_labels = blk: { 44 var labels: [role_choices.len][]const u8 = undefined; 45 for (&labels, role_choices) |*label, role| label.* = roleLabel(role); 46 break :blk labels; 47 }; 48 49 pub fn roleLabel(role_maybe: ?evt.Repo.Role) []const u8 { 50 const role = role_maybe orelse return "nobody"; 51 return switch (role) { 52 .owner, .write => "members", 53 .read => "anybody", 54 }; 55 } 56 57 // the tab role a label names 58 pub fn labelRole(label: []const u8) error{InvalidRole}!?evt.Repo.Role { 59 for (role_choices) |role| { 60 if (std.mem.eql(u8, label, roleLabel(role))) return role; 61 } 62 return error.InvalidRole; 63 } 64 65 // the repo settings, members and branches sub tabs over their content 66 pub const View = struct { 67 box: wgt.Box(ui.Widget), 68 69 const header_index = 0; 70 const content_index = 1; 71 72 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { 73 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .vert }); 74 errdefer box.deinit(allocator); 75 76 { 77 var header = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .horiz }); 78 errdefer header.deinit(allocator); 79 const on_tab = switch (session.data.current_page) { 80 .repo_settings, .repo_members, .repo_branches => true, 81 else => false, 82 }; 83 const routes = [_]?ui.RoutablePage{ ui.RoutablePage.repoSettingsRoute(data.identity), ui.RoutablePage.repoMembersRoute(data.identity, 0), ui.RoutablePage.repoBranchesRoute(data.identity) }; 84 for ([_]@FieldType(Self, "view"){ .settings, .members, .branches }, [_][]const u8{ "repo settings", "members", "branches" }, routes) |view, label, route| { 85 var tab = try wgt.TextBox.init(allocator, label, .{ .border = .hidden, .round_corners = true, .wrap_kind = .none }); 86 errdefer tab.deinit(allocator); 87 tab.getFocus().mode = .all; 88 tab.getFocus().kind = .{ .custom = try ui.inPageTabLink(session, route orelse return error.RouteTooLong, on_tab and data.view == view) }; 89 try header.children.put(allocator, tab.getFocus().id, .{ .widget = .{ .text_box = tab }, .rect = null, .min_size = .{ .width = label.len + 2, .height = null } }); 90 } 91 header.getFocus().child_id = header.children.keys()[@backingInt(data.view)]; 92 // a row taller than the header, leaving a blank line beneath it 93 try box.children.put(allocator, header.getFocus().id, .{ .widget = .{ .box = header }, .rect = null, .min_size = .{ .width = null, .height = 4 } }); 94 } 95 96 { 97 var stack = try wgt.Stack(ui.Widget).init(allocator); 98 errdefer stack.deinit(allocator); 99 { 100 var form = try Form.init(allocator, data, session); 101 errdefer form.deinit(allocator); 102 try stack.children.put(allocator, form.getFocus().id, .{ .repo_settings_form = form }); 103 } 104 { 105 var members = try Members.View.init(allocator, &data.members, session); 106 errdefer members.deinit(allocator); 107 try stack.children.put(allocator, members.getFocus().id, .{ .repo_members = members }); 108 } 109 { 110 var branches = try Branches.View.init(allocator, &data.branches, session); 111 errdefer branches.deinit(allocator); 112 try stack.children.put(allocator, branches.getFocus().id, .{ .repo_branches = branches }); 113 } 114 stack.getFocus().child_id = stack.children.keys()[@backingInt(data.view)]; 115 try box.children.put(allocator, stack.getFocus().id, .{ .widget = .{ .stack = stack }, .rect = null, .min_size = null }); 116 } 117 box.getFocus().child_id = box.children.keys()[content_index]; 118 return .{ .box = box }; 119 } 120 121 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 122 self.box.deinit(allocator); 123 } 124 125 fn headerBox(self: *View) *wgt.Box(ui.Widget) { 126 return &self.box.children.values()[header_index].widget.box; 127 } 128 129 fn contentStack(self: *View) *wgt.Stack(ui.Widget) { 130 return &self.box.children.values()[content_index].widget.stack; 131 } 132 133 fn headerActive(self: *View) bool { 134 return self.box.getFocus().child_id == self.headerBox().getFocus().id; 135 } 136 137 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 138 self.clearGrid(); 139 const header = self.headerBox(); 140 if (header.getFocus().child_id) |selected_id| { 141 for (header.children.keys(), header.children.values(), 0..) |id, *child, index| { 142 ui.widget.markSelected(&child.widget.text_box, id == selected_id); 143 if (id == selected_id) self.contentStack().getFocus().child_id = self.contentStack().children.keys()[index]; 144 } 145 } 146 try self.box.build(allocator, constraint, root_focus); 147 } 148 149 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 150 const direction = inp.vertDirection(key); 151 if (self.headerActive()) { 152 const header = self.headerBox(); 153 if (direction == .down) { 154 root_focus.setFocus(self.contentStack().getFocus().id); 155 } else if (header.children.getIndex(header.getFocus().child_id orelse return)) |current| { 156 if (inp.moveTab(key, current, header.children.count())) |next| root_focus.setFocus(header.children.keys()[next]); 157 } 158 return; 159 } 160 if (direction == .up) if (self.contentStack().getSelected()) |selected| if (selected.atTop(root_focus)) { 161 root_focus.setFocus(self.headerBox().getFocus().id); 162 return; 163 }; 164 try self.contentStack().input(allocator, key, root_focus); 165 } 166 167 // moving up from the sub header returns to the page's tabs 168 pub fn atTop(self: *View) bool { 169 return self.headerActive(); 170 } 171 172 // arriving from the page's tabs lands on the sub header, not the content 173 pub fn focusHeader(self: *View, root_focus: *Focus) bool { 174 root_focus.setFocus(self.headerBox().getFocus().id); 175 return true; 176 } 177 178 pub fn clearGrid(self: *View) void { 179 self.box.clearGrid(); 180 } 181 182 pub fn getGrid(self: View) ?Grid { 183 return self.box.getGrid(); 184 } 185 186 pub fn getFocus(self: *View) *Focus { 187 return self.box.getFocus(); 188 } 189 }; 190 191 pub const Form = struct { 192 // a centered form, scrolled when it outgrows the window 193 scroll: wgt.Scroll(ui.Widget), 194 data: *const Self, 195 session: *ui.Session, 196 197 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !Form { 198 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .vert }); 199 errdefer box.deinit(allocator); 200 const route = ui.RoutablePage.repoSettingsRoute(data.identity) orelse return error.RouteTooLong; 201 box.getFocus().kind = .{ .custom = try session.page_arena.allocator().print("form:{s}", .{try route.toUrl(session.page_arena)}) }; 202 203 const saved_fields = if (session.formFeedback(.repo_settings)) |saved| saved.fields else null; 204 205 { 206 var name = try wgt.TextInput.init(allocator, .{ .top_label = .{ .text = " name " }, .name = "name", .visible_width = 30, .round_corners = true, .render_content = session.is_terminal }); 207 errdefer name.deinit(allocator); 208 name.getFocus().mode = .all; 209 try name.setContent(allocator, if (saved_fields) |saved| saved.name else data.name); 210 // show the start of the prefilled text 211 name.cursor = 0; 212 try box.children.put(allocator, name.getFocus().id, .{ .widget = .{ .text_input = name }, .rect = null, .min_size = .{ .width = null, .height = 3 } }); 213 box.getFocus().child_id = name.getFocus().id; 214 } 215 216 { 217 var description = try wgt.TextInput.init(allocator, .{ .top_label = .{ .text = " description " }, .name = "description", .visible_width = 30, .round_corners = true, .render_content = session.is_terminal }); 218 errdefer description.deinit(allocator); 219 description.getFocus().mode = .all; 220 try description.setContent(allocator, if (saved_fields) |saved| saved.description else data.description); 221 // show the start of the prefilled text 222 description.cursor = 0; 223 try box.children.put(allocator, description.getFocus().id, .{ .widget = .{ .text_input = description }, .rect = null, .min_size = .{ .width = null, .height = 3 } }); 224 } 225 226 { 227 const access = if (saved_fields) |saved| saved.access else data.access; 228 var access_radio = try ui.widget.Radio.init(allocator, session, "access", &.{ "private", "public" }, @tagName(access), null); 229 errdefer access_radio.deinit(allocator); 230 try box.children.put(allocator, access_radio.getFocus().id, .{ .widget = .{ .radio = access_radio }, .rect = null, .min_size = .{ .width = null, .height = 3 } }); 231 } 232 233 { 234 var hash_box = try wgt.TextBox.init(allocator, @tagName(data.hash_kind), .{ .border = .single, .round_corners = true, .wrap_kind = .none, .top_label = .{ .text = " hash " } }); 235 errdefer hash_box.deinit(allocator); 236 try box.children.put(allocator, hash_box.getFocus().id, .{ .widget = .{ .text_box = hash_box }, .rect = null, .min_size = .{ .width = null, .height = 3 } }); 237 } 238 239 const roles = [_]struct { name: []const u8, label: []const u8, role: ?evt.Repo.Role }{ 240 .{ .name = "discuss_role", .label = " new discussions can be made by ", .role = if (saved_fields) |saved| saved.discuss_role else data.discuss_role }, 241 .{ .name = "issue_role", .label = " new issues can be made by ", .role = if (saved_fields) |saved| saved.issue_role else data.issue_role }, 242 .{ .name = "patch_role", .label = " new patches can be made by ", .role = if (saved_fields) |saved| saved.patch_role else data.patch_role }, 243 }; 244 for (roles) |role| { 245 var role_radio = try ui.widget.Radio.init(allocator, session, role.name, &role_labels, roleLabel(role.role), role.label); 246 errdefer role_radio.deinit(allocator); 247 try box.children.put(allocator, role_radio.getFocus().id, .{ .widget = .{ .radio = role_radio }, .rect = null, .min_size = null }); 248 } 249 250 { 251 var submit = try ui.widget.SubmitButton.initLabeled(allocator, "submit changes"); 252 errdefer submit.deinit(allocator); 253 try box.children.put(allocator, submit.getFocus().id, .{ .widget = .{ .submit_button = submit }, .rect = null, .min_size = .{ .width = null, .height = 3 } }); 254 } 255 256 var center = try ui.widget.Center.init(allocator, .{ .box = box }); 257 errdefer center.deinit(allocator); 258 return .{ 259 .scroll = try wgt.Scroll(ui.Widget).init(allocator, .{ .center = center }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }), 260 .data = data, 261 .session = session, 262 }; 263 } 264 265 pub fn deinit(self: *Form, allocator: std.mem.Allocator) void { 266 self.scroll.deinit(allocator); 267 } 268 269 pub fn build(self: *Form, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 270 self.clearGrid(); 271 const failure = if (self.session.formFeedback(.repo_settings)) |saved| saved.failure else null; 272 (try formField(self.formBox(), "name")).options.top_label.text = if (failure) |value| switch (value) { 273 .required_name => " name (required) ", 274 .invalid_name => " name (invalid) ", 275 .name_taken => " name (taken) ", 276 } else " name "; 277 // the web form handling finds the inputs by focus id 278 const inputs_arena = self.session.arena.allocator(); 279 for (self.formBox().children.values()) |*child| switch (child.widget) { 280 .text_input => |*ti| try self.session.text_inputs.put(inputs_arena, ti.getFocus().id, ti), 281 else => {}, 282 }; 283 // the window's height as a minimum centers a form that fits 284 try self.scroll.build(allocator, .{ 285 .min_size = .{ .width = constraint.min_size.width, .height = constraint.max_size.height orelse constraint.min_size.height }, 286 .max_size = constraint.max_size, 287 }, root_focus); 288 } 289 290 pub fn input(self: *Form, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 291 const cid = self.formBox().getFocus().child_id orelse return; 292 const cur = self.formBox().children.getIndex(cid) orelse return; 293 const child = &self.formBox().children.values()[cur]; 294 const keys = self.formBox().children.keys(); 295 296 const on_submit = child.widget == .submit_button; 297 const direction = inp.vertDirection(key); 298 const up = direction == .up or key == .back_tab; 299 if (up or direction == .down or key == .tab) { 300 var next = cur; 301 while (true) { 302 if (up) { 303 if (next == 0) return; 304 next -= 1; 305 } else { 306 next += 1; 307 if (next == keys.len) return; 308 } 309 // the read-only hash row is stepped over 310 if (self.formBox().children.values()[next].widget != .text_box) break; 311 } 312 root_focus.setFocus(keys[next]); 313 if (self.session.is_terminal) if (self.formBox().children.values()[next].rect) |rect| ui.widget.scrollToCenteredRect(&self.scroll, rect); 314 return; 315 } 316 switch (key) { 317 .enter => if (on_submit) return self.submitForm(allocator), 318 .mouse => |mouse| if (on_submit) { 319 if (inp.leftClickOn(root_focus, child.widget.submit_button.buttonId(), mouse)) return self.submitForm(allocator); 320 }, 321 else => {}, 322 } 323 try child.widget.input(allocator, key, root_focus); 324 } 325 326 fn formRadio(form: *wgt.Box(ui.Widget), name: []const u8) !*ui.widget.Radio { 327 for (form.children.values()) |*child| switch (child.widget) { 328 .radio => |*radio| if (std.mem.eql(u8, radio.name, name)) return radio, 329 else => {}, 330 }; 331 return error.MissingFormField; 332 } 333 334 fn formField(form: *wgt.Box(ui.Widget), name: []const u8) !*wgt.TextInput { 335 for (form.children.values()) |*child| switch (child.widget) { 336 .text_input => |*text_input| if (std.mem.eql(u8, text_input.options.name, name)) return text_input, 337 else => {}, 338 }; 339 return error.MissingFormField; 340 } 341 342 // update the repo and navigate to its new url. this is the terminal path; 343 // the web posts the form to the settings route. 344 fn submitForm(self: *Form, allocator: std.mem.Allocator) !void { 345 if (comptime wasm) return; 346 const io = self.session.io orelse return; 347 const users_dir = self.session.users_dir orelse return; 348 const actor = (try self.session.authorize(self.data.identity, .owner, null)) orelse return; 349 350 const name = try (try formField(self.formBox(), "name")).text(allocator); 351 defer allocator.free(name); 352 const description = try (try formField(self.formBox(), "description")).text(allocator); 353 defer allocator.free(description); 354 const access = std.meta.stringToEnum(evt.Repo.Access, (try formRadio(self.formBox(), "access")).selected()) orelse unreachable; 355 const discuss_role = labelRole((try formRadio(self.formBox(), "discuss_role")).selected()) catch unreachable; 356 const issue_role = labelRole((try formRadio(self.formBox(), "issue_role")).selected()) catch unreachable; 357 const patch_role = labelRole((try formRadio(self.formBox(), "patch_role")).selected()) catch unreachable; 358 359 const route = update(io, allocator, users_dir, actor, self.data.identity, name, description, access, discuss_role, issue_role, patch_role) catch |err| { 360 const failure = ui.Session.FormFeedback.RepoFailure.fromError(err) orelse return err; 361 const aa = self.session.arena.allocator(); 362 self.session.data.form_feedback = .{ .repo_settings = .{ .failure = failure, .fields = .{ 363 .name = try aa.dupe(u8, name), 364 .description = try aa.dupe(u8, description), 365 .access = access, 366 .discuss_role = discuss_role, 367 .issue_role = issue_role, 368 .patch_role = patch_role, 369 } } }; 370 return; 371 }; 372 try self.session.navigate(route); 373 } 374 375 fn formBox(self: *Form) *wgt.Box(ui.Widget) { 376 return &self.scroll.child.center.child.box; 377 } 378 379 pub fn clearGrid(self: *Form) void { 380 self.scroll.clearGrid(); 381 } 382 383 pub fn getGrid(self: Form) ?Grid { 384 return self.scroll.getGrid(); 385 } 386 387 pub fn getFocus(self: *Form) *Focus { 388 return self.scroll.getFocus(); 389 } 390 391 // up leaves the form from its first control 392 pub fn atTop(self: Form) bool { 393 const box = &self.scroll.child.center.child.box; 394 return box.focus.child_id == box.children.keys()[0]; 395 } 396 }; 397 398 // change the repo `identity` names as `actor`, its owner, returning its 399 // settings route under its new name 400 pub fn update( 401 io: std.Io, 402 allocator: std.mem.Allocator, 403 users_dir: []const u8, 404 actor: ui.Actor, 405 identity: []const u8, 406 name: []const u8, 407 description: []const u8, 408 access: evt.Repo.Access, 409 discuss_role: ?evt.Repo.Role, 410 issue_role: ?evt.Repo.Role, 411 patch_role: ?evt.Repo.Role, 412 ) !ui.RoutablePage { 413 const parsed = ui.RoutablePage.RepoIdentity.parse(identity) orelse return error.NotFound; 414 const owner_id = actor.repo_user_id orelse return error.NotFound; 415 try evt.updateRepo(io, allocator, users_dir, &owner_id, actor.author, parsed.name, name, description, access, discuss_role, issue_role, patch_role); 416 var buf: [ui.RoutablePage.repo_route_max_len]u8 = undefined; 417 const new_identity = std.fmt.bufPrint(&buf, "{s}:{s}", .{ parsed.owner, name }) catch return error.RouteTooLong; 418 return ui.RoutablePage.repoSettingsRoute(new_identity) orelse error.RouteTooLong; 419 } 420