Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,8 @@ jobs:
echo "code=$code" >> "$GITHUB_OUTPUT"
echo "sdl=$sdl" >> "$GITHUB_OUTPUT"

# Every tracked .zig and .zon file as `zig fmt` writes it. Needs Zig and nothing else: no
# dependencies, no cache, a few seconds.
# Formatting, lint and script regression checks. Needs Zig, Python and shell tools,
# but no app dependencies or build cache.
format:
name: Format
needs: changes
Expand All @@ -132,6 +132,8 @@ jobs:
- name: lint
# Not `zig build lint`: the root build fetches the app's URL dependencies.
run: zig run scripts/lint.zig
- name: ci-ok result policy
run: python3 scripts/test-ci-ok.py
- name: scripts/zb (build lock)
run: sh scripts/test-zb.sh
- name: scripts/check-kickoff
Expand Down
9 changes: 0 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ Fizzy (Editor) ←── Host registries + EditorAPI ──→ Plugin (register(
- **`archive`** — opens a `.zip` as a mounted folder (the web's "open a vault" path, too).
- **`devtools`** — the dashboard: where the app's time goes, from `core.profile`, for a person and an agent alike.
- **`guides`** — every demo the app can play, on the rail: fizzy's tour and each plugin's own, by chapter, through the `demos` service.
- **`chat`** — a conversation and its composer over the `chat.provider` service; owns no conversation (a small in-memory demo provider when none is offered). Not in fizzy's own bundle: an app lists it.
- `shared` — build helpers used across plugins' `static/integration.zig` (not a plugin itself).

**Pixi (pixel-art editor) and Drive (Google Drive as a mount) live outside this repo** as third-party-style plugins ([`fizzyedit/pixi`](https://github.com/fizzyedit/pixi), [`fizzyedit/drive`](https://github.com/fizzyedit/drive)) — they ship and update purely through the plugin store (`docs/PLUGINS.md` §6), with no special treatment in Fizzy itself. **Trust `ls plugins/` and `jj log` over any doc's plugin list.**
Expand Down Expand Up @@ -104,14 +103,6 @@ both against each pull request's own tree (`scripts/check-examples.sh`, which yo
locally): a change here that breaks either fails here. A change that needs them changed too
lands its PR in that repo alongside.

An app that wants fizzy's own shape with a different frame sets the **`Chrome`** build options
(`src/editor/Chrome.zig`): `-Dchrome-sidebar-edge=right` puts the rail and Sidebar on the right
(the width stays the Sidebar's `extent`), `-Dchrome-panel=false` leaves out the Panel and fizzy's
Output view, `-Dchrome-menu-bar=false` and `-Dchrome-infobar=false` leave out those bars
(macOS keeps its native menu). The defaults are fizzy's own frame. An app-supplied layout
(`-Dapp-layout=`) draws its own frame. The phone shape reads only `sidebar_edge` (landscape
sheets) and `panel`.

## Plugin store: built, not forward-looking

The plugin registry/install flow (author repo → release CI → `fizzyedit/plugins` registry →
Expand Down
4 changes: 2 additions & 2 deletions app/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,5 +47,5 @@ each directory is.
ReleaseFast). Check against `control.Server.max_path`, the real size, before `init`.
- **On Windows, a socket test must not read to EOF when it need not.** Its AF_UNIX sometimes never
tells a reader that the other side closed, so a test client reading "until the end" hangs about
one run in twenty. Read `Content-Length` bytes, as HTTP clients do, or stop after the expected
complete SSE message for an event stream (`control/Server.zig`'s `TestClient`).
one run in twenty. Read `Content-Length` bytes, as HTTP clients do (`control/Server.zig`'s
`TestClient`). It sent a day after server bugs that were not there.
20 changes: 10 additions & 10 deletions app/HistoryFrames.zig
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ unread: ?Unread = null,
/// while the history moves frame after frame (a held undo, a scrub), so each step is a copy into
/// it rather than a new pane-sized target; let go of the first frame no picture is asked for.
whole: ?dvui.TextureTarget = null,
/// The size of the pane's part a picture is taken from as the pane stands (`take`, each frame).
/// Kept pictures retain their original size even when the pane resizes.
/// The size of the pane's part a picture is taken from as the pane stands (`take`, each frame): a
/// step's picture from another size (the window resized since) is of another view of it, and a
/// visit takes it again.
pane_source: [2]u32 = .{ 0, 0 },

const Unread = struct {
Expand Down Expand Up @@ -112,8 +113,8 @@ pub const max_w: u32 = 384;
/// document drew) are seen the frame after, which draws them.
pub fn moved(self: *HistoryFrames, gpa: std.mem.Allocator, top: u64, below: ?u64, change: Change) void {
if (change == .restarted) self.forget(gpa);
// A visit keeps the original pixels and change box, even after the pane resizes.
if (change == .visited and self.frames.contains(top)) return;
// Only visited, and it has its picture of the pane as it stands: kept as it was taken.
if (change == .visited) if (self.frames.get(top)) |pic| if (std.mem.eql(u32, &pic.source, &self.pane_source)) return;
self.due = .{ .top = top, .below = below, .made = change != .visited };
// Keep the frame in a texture to read from, on a backend that would draw it straight to the
// window (the web).
Expand Down Expand Up @@ -190,11 +191,12 @@ fn land(self: *HistoryFrames, gpa: std.mem.Allocator) void {
} else |_| {}
} else |_| {}
const top = u.step.top;
// A picture may have landed since this visit was requested: keep that original.
if (!u.step.made and self.frames.contains(top)) {
// A visit's picture of a step that has one of this view by now (made since it was asked for)
// is not the step's: it keeps its own.
if (!u.step.made) if (self.frames.get(top)) |old| if (std.mem.eql(u32, &old.source, &pic.source)) {
gpa.free(pic.prints);
return;
}
};
// Round what the step changed; one that changed nothing visible, round the document's content,
// or the pane's middle.
const round = pic.change orelse content orelse rules.Frac{};
Expand Down Expand Up @@ -378,9 +380,7 @@ test "scrubbing across the steps leaves each step's picture as it was taken" {
}
// Back to the start and forward again, then to and fro: undo, redo, a scrub's jumps.
const stops = [_]u64{ 4, 3, 2, 1, 0, 1, 2, 3, 4, 0, 4, 2 };
for (stops, 0..) |top, visit| {
// Opening the strip or resizing the pane must not retake a kept picture.
hf.pane_source = if (visit % 2 == 0) .{ 800, 500 } else .{ 800, 430 };
for (stops) |top| {
hf.moved(gpa, top, if (top == 0) null else top - 1, changeOf(&.{}, top));
try std.testing.expectEqual(@as(?Step, null), hf.due);
}
Expand Down
4 changes: 2 additions & 2 deletions app/Tracks.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1177,7 +1177,7 @@ pub fn exportTape(self: *Tracks, host: *sdk.Host, doc: sdk.DocHandle, gpa: std.m
const files = try arena.alloc(tape.Tape.File, 1);
files[0] = .{ .path = name, .text = bytes, .owner = doc.owner.id, .format = t.format };
const keyframes = try arena.alloc(tape.Tape.Keyframe, 1);
keyframes[0] = .{ .root = "demo://share", .files = files, .open = try arena.dupe([]const u8, &.{name}) };
keyframes[0] = .{ .root = "demo://share", .files = files, .open = try arena.dupe([]const u8, &.{name}), .layout = .focused };
const ops = try arena.alloc(tape.Tape.Op, to - first + 2);
ops[0] = .{ .at = 0, .do = .{ .keyframe = 0 } };
ops[1] = .{ .at = 0, .do = .{ .wait = .{ .until = .idle } } };
Expand Down Expand Up @@ -1288,7 +1288,7 @@ pub fn exportLine(self: *Tracks, host: *sdk.Host, doc: sdk.DocHandle, gpa: std.m
const files = try arena.alloc(tape.Tape.File, 1);
files[0] = .{ .path = name, .text = bytes, .owner = doc.owner.id, .format = t.format };
const keyframes = try arena.alloc(tape.Tape.Keyframe, 1);
keyframes[0] = .{ .root = "demo://share", .files = files, .open = try arena.dupe([]const u8, &.{name}) };
keyframes[0] = .{ .root = "demo://share", .files = files, .open = try arena.dupe([]const u8, &.{name}), .layout = .focused };
const ops = try arena.alloc(tape.Tape.Op, recs.len + 2);
ops[0] = .{ .at = 0, .do = .{ .keyframe = 0 } };
ops[1] = .{ .at = 0, .do = .{ .wait = .{ .until = .idle } } };
Expand Down
88 changes: 16 additions & 72 deletions app/control/Server.zig
Original file line number Diff line number Diff line change
Expand Up @@ -721,8 +721,6 @@ const TestClient = struct {
io: Io,
path: []const u8,
request: []const u8,
/// A complete final SSE message: streams have no Content-Length or reliable EOF.
event_end: ?[]const u8 = null,
reply: [8192]u8 = undefined,
reply_len: usize = 0,

Expand All @@ -737,13 +735,11 @@ const TestClient = struct {
w.interface.flush() catch return;
var rbuf: [1024]u8 = undefined;
var r = stream.reader(self.io, &rbuf);
self.readReply(&r.interface);
}

fn readReply(self: *TestClient, reader: *Io.Reader) void {
// Read only a framed response: EOF is not reliable on Windows AF_UNIX.
// As an HTTP client does: the head, then `Content-Length` bytes when it says, else to the
// end (a stream). Never to the end when it need not: whether Windows' AF_UNIX tells a
// reader the other side closed is not something to test here.
while (self.reply_len < self.reply.len) {
const n = reader.readSliceShort(self.reply[self.reply_len..][0..1]) catch return;
const n = r.interface.readSliceShort(self.reply[self.reply_len..][0..1]) catch return;
if (n == 0) return;
self.reply_len += 1;
if (std.mem.endsWith(u8, self.reply[0..self.reply_len], "\r\n\r\n")) break;
Expand All @@ -754,37 +750,14 @@ const TestClient = struct {
const end = std.mem.findPos(u8, head, i, "\r\n") orelse return;
const len = std.fmt.parseInt(usize, head[i + key.len .. end], 10) catch return;
const want = @min(len, self.reply.len - self.reply_len);
reader.readSliceAll(self.reply[self.reply_len..][0..want]) catch return;
r.interface.readSliceAll(self.reply[self.reply_len..][0..want]) catch return;
self.reply_len += want;
} else if (self.event_end) |end| {
while (self.reply_len < self.reply.len) {
const n = reader.readSliceShort(self.reply[self.reply_len..][0..1]) catch return;
if (n == 0) return;
self.reply_len += 1;
if (std.mem.endsWith(u8, self.reply[0..self.reply_len], end)) return;
}
} else {
self.reply_len += r.interface.readSliceShort(self.reply[self.reply_len..]) catch 0;
}
}
};

test "the HTTP test client stops at the response boundary without waiting for EOF" {
const cases = [_]struct { response: []const u8, event_end: ?[]const u8 = null }{
.{ .response = "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nhi" },
.{ .response = "HTTP/1.1 204 No Content\r\n\r\n" },
.{ .response = "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\r\nevent: end\ndata: {}\n\n", .event_end = "event: end\ndata: {}\n\n" },
};
for (cases) |case| {
// Bytes after the response stand in for an open connection: none may be consumed.
const bytes = try std.mem.concat(std.testing.allocator, u8, &.{ case.response, "still open" });
defer std.testing.allocator.free(bytes);
var reader: Io.Reader = .fixed(bytes);
var client: TestClient = .{ .io = std.testing.io, .path = "", .request = "", .event_end = case.event_end };
client.readReply(&reader);
try std.testing.expectEqualStrings(case.response, client.reply[0..client.reply_len]);
try std.testing.expectEqualStrings("still open", reader.buffered());
}
}

test "a request reaches the UI thread and its answer goes back" {
if (!Io.net.has_unix_sockets) return error.SkipZigTest;
const gpa = std.testing.allocator;
Expand Down Expand Up @@ -994,7 +967,7 @@ test "a server that took over the path keeps it when the old one stops" {
try std.testing.expect(std.mem.endsWith(u8, client.reply[0..client.reply_len], "\r\n\r\nnew"));
}

test "a stream gets only the events it asked for, once each" {
test "a stream gets the events it asked for, once each, until the server stops" {
if (!Io.net.has_unix_sockets) return error.SkipZigTest;
const gpa = std.testing.allocator;
const io = std.testing.io;
Expand All @@ -1009,12 +982,7 @@ test "a stream gets only the events it asked for, once each" {
var server: Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = noWake };
try server.start();
errdefer server.stop();
var client: TestClient = .{
.io = io,
.path = path,
.request = "GET /events?topics=fizzy.document.*,chat.turn_ended HTTP/1.1\r\nHost: x\r\n\r\n",
.event_end = "event: chat.turn_ended\ndata: {}\n\n",
};
var client: TestClient = .{ .io = io, .path = path, .request = "GET /events?topics=fizzy.document.*,chat.turn_ended HTTP/1.1\r\nHost: x\r\n\r\n" };
const thread = try std.Thread.spawn(.{}, TestClient.run, .{&client});

var taken: std.ArrayList(*Request) = .empty;
Expand All @@ -1031,9 +999,10 @@ test "a stream gets only the events it asked for, once each" {
server.publish("fizzy.document.saved", "{\"path\":\"/a\"}");
server.publish("fizzy.folder.opened", "{\"path\":\"/p\"}");
server.publish("chat.turn_ended", "{}");
// Wait for the final expected message before stopping can cancel the writer.
thread.join();
// Let the stream write before the stop ends it.
io.sleep(.fromMilliseconds(50), .awake) catch {};
server.stop();
thread.join();

const reply = client.reply[0..client.reply_len];
try std.testing.expect(std.mem.startsWith(u8, reply, "HTTP/1.1 200 OK\r\n"));
Expand Down Expand Up @@ -1174,7 +1143,7 @@ test "a stopped server ends a stream whose client stopped reading" {
server.stop();
}

test "an event published as the stream is answered arrives while the stream stays open" {
test "an event published as the stream is answered is not lost" {
if (!Io.net.has_unix_sockets) return error.SkipZigTest;
const gpa = std.testing.allocator;
const io = std.testing.io;
Expand All @@ -1188,12 +1157,7 @@ test "an event published as the stream is answered arrives while the stream stay

var server: Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = noWake };
try server.start();
var client: TestClient = .{
.io = io,
.path = path,
.request = "GET /events HTTP/1.1\r\nHost: x\r\n\r\n",
.event_end = "event: first\ndata: 1\n\n",
};
var client: TestClient = .{ .io = io, .path = path, .request = "GET /events HTTP/1.1\r\nHost: x\r\n\r\n" };
const thread = try std.Thread.spawn(.{}, TestClient.run, .{&client});
var taken: std.ArrayList(*Request) = .empty;
defer taken.deinit(gpa);
Expand All @@ -1206,9 +1170,9 @@ test "an event published as the stream is answered arrives while the stream stay
// though its connection has not written the headers yet.
taken.items[0].answerStream(&server);
server.publish("first", "1");
// The client must finish on the message, while the server still holds the stream open.
thread.join();
io.sleep(.fromMilliseconds(50), .awake) catch {};
server.stop();
thread.join();
try std.testing.expect(std.mem.endsWith(u8, client.reply[0..client.reply_len], "\r\n\r\nevent: first\ndata: 1\n\n"));
}

Expand Down Expand Up @@ -1399,23 +1363,3 @@ test "a client that leaves an upgraded connection finishes its link" {
test {
_ = Link;
}

test "a thousand listeners stop without a client waking accept" {
if (!Io.net.has_unix_sockets) return error.SkipZigTest;
const gpa = std.testing.allocator;
const io = std.testing.io;
const dog = try Watchdog.start(gpa, io, @src().fn_name);
defer dog.stop();
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try testSocket(gpa, &tmp);
defer gpa.free(path);
defer removeTestSocket(path);

// Reuse the path, as repeated launches in one profile do.
for (0..1000) |_| {
var server: Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = noWake };
try server.start();
server.stop();
}
}
6 changes: 3 additions & 3 deletions app/history_transport.zig
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,9 @@ pub fn stopAt(x: f32, x0: f32, x1: f32, n: usize) usize {
}

/// Where stop `i` of `n` sits on the line from `x0` to `x1`: evenly spaced, stop 0 at `x0` and
/// stop `n` at `x1`, so the knob reaches both ends. With no history, the one stop is at `x0`.
/// stop `n` at `x1`, so the knob reaches both ends. With no history, the one stop is in the middle.
pub fn stopX(i: usize, x0: f32, x1: f32, n: usize) f32 {
if (n == 0) return x0;
if (n == 0) return (x0 + x1) / 2;
return x0 + (x1 - x0) * @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(n));
}

Expand Down Expand Up @@ -344,7 +344,7 @@ test "stop 0 is at the left end, stop n at the right, and stopAt inverts stopX"
try testing.expectApproxEqAbs(@as(f32, 220), stopX(n, 20, 220, n), 1e-3);
for (0..n + 1) |i| try testing.expectEqual(i, stopAt(stopX(i, 20, 220, n), 20, 220, n));
}
try testing.expectEqual(@as(f32, 20), stopX(0, 20, 220, 0));
try testing.expectEqual(@as(f32, 120), stopX(0, 20, 220, 0));
}

test "a bar told to go goes from where it is, and one left goes soon" {
Expand Down
9 changes: 2 additions & 7 deletions app/layout/Floats.zig
Original file line number Diff line number Diff line change
Expand Up @@ -209,11 +209,6 @@ const ghost_blur: f32 = 0.55;
/// Where `Float.aside` goes for a ghost closing — its last view landed elsewhere: past the ghost,
/// to nothing.
const gone: f32 = 2;
/// The leading traffic light of a titled macOS window, points: its diameter, and how far its left
/// edge sits in from the window's. A tape presses the same spot in the frame, since the OS draws
/// the button outside it.
const traffic_light_diameter: f32 = 12;
const traffic_light_inset: f32 = 7;

/// How a float looks `v` of the way aside (`Float.aside`): itself at 0, its ghost at 1, nothing
/// at `gone` — how much of it shows, and how far out of focus.
Expand Down Expand Up @@ -684,8 +679,8 @@ fn drawOne(l: *Layout, i: usize) bool {
// click there never reaches it. A tape aims at the same spot in the frame (the leading
// traffic light), and a press there closes the float as the OS button does.
if (header_close == null and osButtons(first) and core.anchor.wanted()) {
const side = traffic_light_diameter * scale;
const spot: dvui.Rect.Physical = .{ .x = header.x + traffic_light_inset * scale, .y = header.y + (header.h - side) / 2, .w = side, .h = side };
const side = 12 * scale;
const spot: dvui.Rect.Physical = .{ .x = header.x + 7 * scale, .y = header.y + (header.h - side) / 2, .w = side, .h = side };
core.anchor.markRect(win_id, spot, true, "float-close:{s}", .{first.name});
for (dvui.events()) |*e| {
if (e.evt != .mouse or e.handled) continue;
Expand Down
8 changes: 3 additions & 5 deletions app/layout/Region.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1437,9 +1437,7 @@ fn initTree(
/// A turn is the new half sliding shut along the old axis (`_fold`, which its place reads as a
/// target of zero), then easing open along the new one. Its share of the box is kept through the
/// turn (`_share`, taken from the extent when that was last chosen, not from a box that has since
/// changed shape), and follows a drag while turned. That drag is shown and nothing more: turning
/// back takes the share from the saved one again (`_seen` is unread while turned, so dvui drops it
/// and the first frame back reads the saved share anew).
/// changed shape), and follows a drag while turned.
fn orientBranch(self: *Layout, saved: SplitTree.Branch, id: dvui.Id) SplitTree.Branch {
if (!saved.adaptive) return saved;
const dims = dvui.dataGet(null, id, "_dims", dvui.Size) orelse return saved;
Expand All @@ -1464,10 +1462,10 @@ fn orientBranch(self: *Layout, saved: SplitTree.Branch, id: dvui.Id) SplitTree.B
}
}
} else if (chosen) |extent| {
// Dragged since the branch set it: the drag is the share drawn, and is not saved.
// Dragged since the branch set it: the drag is the new share.
const set = dvui.dataGet(null, id, "_set", f32) orelse extent;
const room = alongSize(drawn.dir, dims);
if (set != extent and room > 0) dvui.dataSet(null, id, "_share", std.math.clamp(extent / room, 0, 1));
if (set != extent and room > 0) setShare(self, saved.created, id, std.math.clamp(extent / room, 0, 1));
}
const share = dvui.dataGet(null, id, "_share", f32) orelse return drawn;

Expand Down
Loading
Loading