Skip to content

Commit e9c40c1

Browse files
committed
refine settings, fix text input hotkeys, reload plugins on change
1 parent 8f28ff0 commit e9c40c1

17 files changed

Lines changed: 657 additions & 93 deletions

assets/macos/info.plist

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,8 @@
222222
<string>xml</string>
223223
<string>yaml</string>
224224
<string>yml</string>
225+
<string>zig</string>
226+
<string>zon</string>
225227
<string>zlogin</string>
226228
<string>zlogout</string>
227229
<string>zprofile</string>

docs/PLUGINS.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,18 @@ id inside the shell's own `{config}/settings.zon`:
369369
}
370370
```
371371

372+
**String fields own their memory.** A `[]const u8` setting's bytes belong to the schema —
373+
allocated with the host allocator by `applyZon` (loading, or an external hand-edit) and by the
374+
settings pane's text entry through `Access.setString`*unless* the slice is still exactly the
375+
default you declared, which lives in your plugin image's constant data. `Schema(T)` tests that by
376+
pointer identity, so it never hands a string literal to the allocator, and a persisted value that
377+
happens to equal the default is still schema-owned (comparisons for persistence are by content,
378+
so it still drops out of `settings.zon`). What this means for you: **if your settings struct has a
379+
string field, call `MySettings.deinit(&settings)` when you tear that value down** (plugin
380+
`deinit`). It resets the value to your declared defaults and is a no-op for a struct of only
381+
scalars/enums. Don't free a string field yourself, and don't assign one directly if you want it
382+
persisted — go through the settings pane or `applyZon`, or you'll leak the schema's copy.
383+
372384
Author fields live under `.settings` so they can never collide with the shell-reserved
373385
`.enabled`. Only fields that differ from `T`'s own declared defaults are written; an all-default
374386
value removes that plugin's `.settings` (and if also disabled, the whole `.plugins.<id>` entry).

docs/PLUGIN_MANIFEST_PLAN.md

Lines changed: 3 additions & 1 deletion
Large diffs are not rendered by default.

sdk/plugin_sdk.zig

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -390,7 +390,13 @@ const DevInstall = struct {
390390
const src = self.lib.getEmittedBin().getPath2(b, step);
391391
const dest = try std.fs.path.join(b.allocator, &.{ dir, self.file_name });
392392
const data = try std.Io.Dir.cwd().readFileAlloc(io, src, b.allocator, .limited(512 * 1024 * 1024));
393-
try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = dest, .data = data });
393+
// Sibling temp file + rename, not a truncating write onto `dest`: a running fizzy may be
394+
// reading `dest` right now (`PluginLoader.stableLoadCopyPath` copies from it), and on
395+
// Windows a plain overwrite of a file the editor still has open fails outright. The rename
396+
// also means a crash mid-write never leaves a half-written dylib at the load path.
397+
const tmp = try std.fmt.allocPrint(b.allocator, "{s}.part", .{dest});
398+
try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = tmp, .data = data });
399+
try std.Io.Dir.renameAbsolute(tmp, dest, io);
394400
std.log.info("fizzy: installed plugin → {s}", .{dest});
395401
}
396402
};

src/backend/file_assoc.zig

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,8 @@ pub const open_with_only_extensions = [_][]const u8{
6767
".ts", ".tsx", ".txt", ".vb", ".vue",
6868
".wxi", ".wxl", ".wxs", ".xaml", ".xcodeproj",
6969
".xcworkspace", ".xhtml", ".xml", ".yaml", ".yml",
70-
".zlogin", ".zlogout", ".zprofile", ".zsh", ".zshenv",
71-
".zshrc",
70+
".zig", ".zon", ".zlogin", ".zlogout", ".zprofile",
71+
".zsh", ".zshenv", ".zshrc",
7272
};
7373

7474
pub const ExtAssoc = struct {

src/core/darwin_spawn.zig

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,19 @@
99
//! thing the child touches that needs it crashes or hangs. `posix_spawn` doesn't
1010
//! duplicate the caller's address space or thread state at all, so this hazard class
1111
//! doesn't apply regardless of which thread calls it.
12-
//! 2. Every `std.process.spawn`/`.run` call unconditionally walks the process's raw
13-
//! `environ` array (`Io.Threaded.scanEnviron`, "for PATH" — even when argv[0] is already
14-
//! an absolute path and doesn't need PATH resolution at all), and that array is
15-
//! apparently malformed specifically when fizzy is launched via Velopack's installer
16-
//! auto-open right after install (a spurious NULL entry before the array's declared
17-
//! end) — crashing with an unwrap-null panic. There is no public option on
18-
//! `std.process.spawn`/`.run` to skip that scan. This implementation never reads the
19-
//! live `environ` array at all — the child's environment is built from individual
20-
//! `getenv()` lookups for a known set of keys, which degrade gracefully (stop at
21-
//! whatever they find) rather than crash on the same malformed data.
12+
//! 2. Every `std.process.spawn`/`.run` call walks the `environ` array *as captured at
13+
//! startup* — `std.start` records `envp` plus its length once, and `Environ`'s block
14+
//! builders then index that slice up to the recorded length. But libc's `unsetenv`
15+
//! shrinks the live array **in place**: entries shift down and the array gets a NULL
16+
//! one slot earlier, while the captured length stays put. So a single `unsetenv`
17+
//! anywhere in the process — SDL, a plugin dylib, a system framework — leaves the
18+
//! captured slice with a NULL before its end, and the *next* spawn from anywhere
19+
//! unwraps it and segfaults the whole app. (Reproduced standalone: capture at startup,
20+
//! `unsetenv` one inherited variable, `std.process.run` → SIGSEGV in
21+
//! `Environ.createPosixBlock`.) There is no public option on `std.process.spawn`/`.run`
22+
//! to skip that walk. This implementation never reads the `environ` array at all — the
23+
//! child's environment is built from individual `getenv()` lookups for a known set of
24+
//! keys, which is unaffected by the in-place shuffle.
2225
//!
2326
//! Returns a normal `std.process.Child` — its `wait`/`kill` are plain POSIX
2427
//! `waitpid`/`kill` calls with no dependency on how the child was spawned, so callers use it

src/core/fuzzy.zig

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,14 @@
1212
//! already order correctly.
1313
//!
1414
//! Matching is case-insensitive until the query itself contains an uppercase character
15-
//! ("smart case"), the convention every interactive fuzzy finder uses: `readme` finds `README`,
16-
//! but `README` no longer finds `readme.txt`. zf's own default is case-sensitive, which is the
17-
//! wrong default for a filter box.
15+
//! ("smart case"), the convention every interactive fuzzy finder uses: `readme` finds `README`.
16+
//! zf's own default is case-sensitive, which is the wrong default for a filter box.
17+
//!
18+
//! Smart case here *prefers* an exact-case match rather than requiring one: a candidate that only
19+
//! matches case-insensitively still matches, it just ranks behind every exact-case hit (see
20+
//! `case_fallback_penalty`). Strict smart case makes a mixed-case query silently return nothing —
21+
//! `Cl` would not find `CLAUDE.md`, because the `l` is upper there — which reads as a broken
22+
//! filter box rather than a precision feature.
1823
const std = @import("std");
1924
const zf = @import("zf");
2025

@@ -67,15 +72,22 @@ pub const Options = struct {
6772
plain: bool = true,
6873
};
6974

75+
/// Added to the score of a candidate that only matched once case was ignored, so exact-case hits
76+
/// always sort ahead of them. Far larger than any rank zf produces for a realistic label or path
77+
/// (those are single or double digits), so the two form clean bands rather than interleaving.
78+
pub const case_fallback_penalty: f64 = 1000;
79+
7080
/// Score `haystack` against `query`. Null means "no match" — the candidate should be dropped,
7181
/// not merely sorted last. **Lower is better**; an empty query scores everything 0.
7282
pub fn score(haystack: []const u8, query: *const Query, opts: Options) ?f64 {
7383
if (query.isEmpty()) return 0;
7484
if (haystack.len == 0) return null;
75-
return zf.rank(haystack, query.tokens(), .{
76-
.case_sensitive = query.case_sensitive,
77-
.plain = opts.plain,
78-
});
85+
if (query.case_sensitive) {
86+
if (zf.rank(haystack, query.tokens(), .{ .case_sensitive = true, .plain = opts.plain })) |s| return s;
87+
const relaxed = zf.rank(haystack, query.tokens(), .{ .case_sensitive = false, .plain = opts.plain }) orelse return null;
88+
return relaxed + case_fallback_penalty;
89+
}
90+
return zf.rank(haystack, query.tokens(), .{ .case_sensitive = false, .plain = opts.plain });
7991
}
8092

8193
/// Best (lowest) score across several fields of one candidate — a store row matching on any of
@@ -99,8 +111,12 @@ pub fn matches(haystack: []const u8, query: *const Query, opts: Options) bool {
99111
/// `highlight_buf_len` unless you know better. Returns empty for an empty query.
100112
pub fn highlight(haystack: []const u8, query: *const Query, buf: []usize, opts: Options) []const usize {
101113
if (query.isEmpty() or haystack.len == 0) return &.{};
114+
// Mirror `score`'s smart-case fallback, or a candidate that only matched with case ignored
115+
// would come back with no (or partial) highlights.
116+
const case_sensitive = query.case_sensitive and
117+
zf.rank(haystack, query.tokens(), .{ .case_sensitive = true, .plain = opts.plain }) != null;
102118
return zf.highlight(haystack, query.tokens(), buf, .{
103-
.case_sensitive = query.case_sensitive,
119+
.case_sensitive = case_sensitive,
104120
.plain = opts.plain,
105121
});
106122
}
@@ -158,7 +174,29 @@ test "smart case" {
158174
var upper = Query.init("README");
159175
try testing.expect(upper.case_sensitive);
160176
try testing.expect(matches("README", &upper, .{}));
161-
try testing.expect(!matches("readme.txt", &upper, .{}));
177+
// Case still only ranks — a wrong-case candidate matches, behind every exact-case one.
178+
try testing.expect(matches("readme.txt", &upper, .{}));
179+
try testing.expect(score("README", &upper, .{}).? < score("readme.txt", &upper, .{}).?);
180+
}
181+
182+
test "a mixed-case query still finds an all-caps candidate" {
183+
var q = Query.init("Cl");
184+
try testing.expect(q.case_sensitive);
185+
try testing.expect(matches("CLAUDE.md", &q, .{ .plain = false }));
186+
187+
// ...and it is highlighted, not left blank by the case-sensitive pass.
188+
var buf: [highlight_buf_len]usize = undefined;
189+
const hits = highlight("CLAUDE.md", &q, &buf, .{ .plain = false });
190+
try testing.expectEqualSlices(usize, &.{ 0, 1 }, hits);
191+
}
192+
193+
test "exact-case hits band ahead of case-folded ones" {
194+
var q = Query.init("Cl");
195+
const exact = score("src/Client.zig", &q, .{ .plain = false }).?;
196+
const folded = score("CLAUDE.md", &q, .{ .plain = false }).?;
197+
try testing.expect(exact < case_fallback_penalty);
198+
try testing.expect(folded >= case_fallback_penalty);
199+
try testing.expect(exact < folded);
162200
}
163201

164202
test "scattered characters match, missing ones do not" {

src/editor/DocumentWatcher.zig

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
//! Watches open on-disk documents for external changes while fizzy is running.
22
//!
33
//! Thin adapter over [`neurocyte/nightwatch`](https://github.com/neurocyte/nightwatch): each
4-
//! tracked path gets its own `watch(path)`. Nightwatch owns the background thread; this layer
5-
//! only implements its `Handler` (atomic flag + `backend.refresh()`, never file content /
6-
//! `dvui.io` / a shared allocator from the callback) and a ~200ms coalesce on the main thread
7-
//! via `tick`.
4+
//! tracked path gets its own `watch(path)` on a `doc_variant` watcher (see that decl — the
5+
//! variant choice is load-bearing for single-file watches). Nightwatch owns the background
6+
//! thread; this layer only implements its `Handler` (atomic flag + `backend.refresh()`, never
7+
//! file content / `dvui.io` / a shared allocator from the callback) and a ~200ms coalesce on
8+
//! the main thread via `tick`.
89
//!
910
//! On a real external change (content hash differs from the baseline captured at open / after
1011
//! our own save):
@@ -54,12 +55,27 @@ by_id: std.AutoHashMapUnmanaged(u64, Entry) = .empty,
5455
/// normalized path → doc id (event matching). Path keys are borrowed from `Entry.path`.
5556
by_path: std.StringHashMapUnmanaged(u64) = .empty,
5657

58+
/// The nightwatch variant to drive open-document watching.
59+
///
60+
/// **Not `Default`.** We watch individual *files*, and on the BSD/macOS `.kqueue` variant a
61+
/// regular-file path silently lands in the directory watch map: its `NOTE_WRITE` is routed to
62+
/// `scan_dir`, which no-ops on a non-directory, so the write is dropped and no handler event
63+
/// ever fires. `.kqueuedir` `fstat`s the path at `add_watch` time and emits real-time
64+
/// `.modified` for regular files — the case nightwatch's README calls out as the supported way
65+
/// to watch single files. Its directory-tree trade-off (mtime-diff scans miss pure content
66+
/// writes) doesn't apply to us: we only ever pass file paths.
67+
const doc_variant: if (have_impl) @import("nightwatch").Variant else void = switch (builtin.os.tag) {
68+
.macos, .freebsd, .openbsd, .netbsd, .dragonfly => .kqueuedir,
69+
else => if (have_impl) @import("nightwatch").default_variant else {},
70+
};
71+
5772
const Impl = if (have_impl) struct {
5873
const nightwatch = @import("nightwatch");
59-
const Handler = nightwatch.Default.Handler;
74+
const Watcher = nightwatch.Create(doc_variant);
75+
const Handler = Watcher.Handler;
6076

6177
handler: Handler = .{ .vtable = &vtable },
62-
nw: ?nightwatch.Default = null,
78+
nw: ?Watcher = null,
6379
raw_dirty: ?*std.atomic.Value(bool) = null,
6480

6581
const vtable = Handler.VTable{
@@ -97,9 +113,8 @@ pub fn init(gpa: Allocator) DocumentWatcher {
97113
/// as `SettingsWatcher.start` — nightwatch retains `&self.impl.handler`).
98114
pub fn start(self: *DocumentWatcher) !void {
99115
if (comptime !have_impl) return error.Unsupported;
100-
const nightwatch = @import("nightwatch");
101116
self.impl.raw_dirty = &self.raw_dirty;
102-
var nw = try nightwatch.Default.init(dvui.io, self.gpa, &self.impl.handler);
117+
var nw = try Impl.Watcher.init(dvui.io, self.gpa, &self.impl.handler);
103118
errdefer nw.deinit();
104119
self.impl.nw = nw;
105120
}

0 commit comments

Comments
 (0)