╭ changed ───╮ │src/main.zig│ ╰────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │105 inline else => |*r| r.core.work_path, │ │106 }; │ │107 break :blk .{ .path = try session_arena.allocator().dupe(u8, work_path), .repo_kind = .xit }; │ │108 - }; │ │109 - │ │110 - // every local route internally carries this synthetic │ │111 - // "owner/name"; urls elide it. │ │112 - const identity = try std.fmt.allocPrint(session_arena.allocator(), "local/{s}", .{std.fs.path.basename(local.path)});│ │113 - const route = ui.RoutablePage.repoFilesRoute(identity, null, "", "", 0) orelse { │ │114 - try run_opts.err.print("repo directory name is too long\n", .{}); │ │115 - return error.HandledError; │ │108 }; │ │109 │ │110 var session = ui.Session{ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │113 .io = io, │ │114 .local = local, │ │115 .is_terminal = true, │ │124 - .data = .{ .current_page = route }, │ │116 + .data = .{ │ │117 + // the files root; local routes carry no identity │ │118 + .current_page = .{ .repo_files = .{ .name = .{} } }, │ │119 + .is_local = true, │ │120 + }, │ │121 + }; │ │122 + │ │123 + // serve the web UI in the background while the TUI runs in the │ │124 + // foreground; the footer points at whatever port it bound. │ │125 + const Runnable = struct { │ │126 + io: std.Io, │ │127 + allocator: std.mem.Allocator, │ │128 + session: *ui.Session, │ │129 + │ │130 + pub fn run(self: @This(), web_port: u16) !void { │ │131 + self.session.web_port = web_port; │ │132 + try ui.run(self.io, self.allocator, self.session, null); │ │133 + } │ │134 }; │ │135 │ │127 - try ui.run(io, allocator, &session, null); │ │136 + try srv.runLocal(io, allocator, local, run_opts.err, Runnable{ .io = io, .allocator = allocator, .session = &session });│ │137 }, │ │138 .cli => |cli_cmd| switch (cli_cmd) { │ │139 .serve => |options| try srv.run(repo_kind, any_repo_opts, io, allocator, cwd_path, options, run_opts.err, {}), │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ────────╮ │src/main_wasm.zig│ ╰─────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────╮ │128 if (key == .enter) { │ │129 if (root_ptr.getFocus().grandchild_id) |gid| { │ │130 // follow a cross-page link │ │131 - if (ui.crossPageLink(root_ptr.getFocus(), gid, session.data.current_page)) |route| {│ │131 + if (ui.crossPageLink(root_ptr.getFocus(), gid, session.data)) |route| { │ │132 const url = try route.urlAlloc(&page_arena); │ │133 _navigate(url.ptr, @intCast(url.len)); │ │134 return; │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │142 fn onMouseClick(focus_id: usize) !void { │ │143 const root_ptr = if (root) |*root_value| root_value else return error.NotStarted; │ │144 // follow a cross-page link │ │145 - if (ui.crossPageLink(root_ptr.getFocus(), focus_id, session.data.current_page)) |route| {│ │145 + if (ui.crossPageLink(root_ptr.getFocus(), focus_id, session.data)) |route| { │ │146 const url = try route.urlAlloc(&page_arena); │ │147 _navigate(url.ptr, @intCast(url.len)); │ │148 return; │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ────╮ │src/serve.zig│ ╰─────────────╯ ╭──────────────────────────────────────────────────────────────────╮ │1 const std = @import("std"); │ │2 const xit = @import("xit"); │ │3 const rp = xit.repo; │ │4 +const ui = @import("./ui.zig"); │ │5 const web = @import("./web.zig"); │ │6 const serve_common = @import("./serve_common.zig"); │ │7 const serve_ssh_protocol = @import("./serve_ssh_protocol.zig");│ ╰──────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────╮ │109 try err.print("serving web UI on http://{s}/\n", .{options.wui_listen}); │ │110 try err.flush(); │ │111 │ │111 - runWebListener(io, allocator, &wui_server, &tasks, admin_repo_path, session_store, err);│ │112 + runWebListener(io, allocator, &wui_server, &tasks, .{ .remote = .{ │ │113 + .admin_repo_path = admin_repo_path, │ │114 + .session_store = session_store, │ │115 + } }, err); │ │116 │ │117 if (@TypeOf(runnable) != void) { │ │118 try runnable.run(); │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │121 } │ │122 } │ │123 │ │124 +// serve just the web UI for a single local repo, running `runnable` (the │ │125 +// local TUI) in the foreground while the listener runs in the background. │ │126 +// when the default port is taken, a random ephemeral port is used instead; │ │127 +// the bound port is passed to `runnable.run`. │ │128 +pub fn runLocal( │ │129 + io: std.Io, │ │130 + allocator: std.mem.Allocator, │ │131 + local: ui.RepoSource, │ │132 + err: *std.Io.Writer, │ │133 + runnable: anytype, │ │134 +) !void { │ │135 + // no reuse_address here: on linux it also sets SO_REUSEPORT, which would │ │136 + // let this bind silently share a port an already-running server holds │ │137 + // instead of failing over to a random one. │ │138 + const wui_listen_address = try parseListenAddress((Options{}).wui_listen); │ │139 + const wui_address = try std.Io.net.IpAddress.parseIp4(wui_listen_address.host, wui_listen_address.port);│ │140 + var wui_server = wui_address.listen(io, .{}) catch |listen_err| switch (listen_err) { │ │141 + // the default port is taken; bind port 0 so the OS assigns a free one │ │142 + error.AddressInUse => blk: { │ │143 + const any_port = try std.Io.net.IpAddress.parseIp4(wui_listen_address.host, 0); │ │144 + break :blk try any_port.listen(io, .{}); │ │145 + }, │ │146 + else => |e| return e, │ │147 + }; │ │148 + defer wui_server.deinit(io); │ │149 + │ │150 + var tasks: std.Io.Group = .init; │ │151 + defer tasks.cancel(io); │ │152 + │ │153 + runWebListener(io, allocator, &wui_server, &tasks, .{ .local = local }, err); │ │154 + │ │155 + try runnable.run(wui_server.socket.address.getPort()); │ │156 +} │ │157 + │ │158 fn runWebListener( │ │159 io: std.Io, │ │160 allocator: std.mem.Allocator, │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────╮ │161 net_server: *std.Io.net.Server, │ │162 tasks: *std.Io.Group, │ │125 - admin_repo_path: []const u8, │ │126 - session_store: web.SessionStore,│ │163 + host: web.Host, │ │164 err: *std.Io.Writer, │ │165 ) void { │ │166 const Context = struct { │ ╰─────────────────────────────────────────╯ ╭─────────────────────────────────────────────╮ │167 io: std.Io, │ │168 allocator: std.mem.Allocator, │ │132 - admin_repo_path: []const u8, │ │133 - session_store: web.SessionStore,│ │169 + host: web.Host, │ │170 err: *std.Io.Writer, │ │171 }; │ │172 │ ╰─────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │173 const handle = struct { │ │174 fn h(ctx: Context, stream: std.Io.net.Stream) void { │ │175 defer stream.close(ctx.io); │ │140 - web.handleConnection(ctx.io, ctx.allocator, stream, ctx.admin_repo_path, ctx.session_store, ctx.err) catch |request_err| {│ │176 + web.handleConnection(ctx.io, ctx.allocator, stream, ctx.host, ctx.err) catch |request_err| { │ │177 serve_common.logError(ctx.err, "web ui request failed: {s}\n", .{@errorName(request_err)}); │ │178 }; │ │179 } │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯