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