v0.9.1
Before you double-click
This build is not signed or notarized, so macOS blocks the first launch. Allow it once:
- macOS 15 Sequoia and later — double-click, dismiss the warning, then open
System Settings ▸ Privacy & Security and click Open Anyway. Apple removed the
Control-click shortcut for unsigned apps in macOS 15, so right-clicking does not help.- macOS 13–14 — right-click the app ▸ Open, then confirm.
Only the first launch asks.
Docker as a drive, directory synchronisation worked through end to end, and a round of things
reported by the people using it.
A running container's filesystem browses like any other volume — its own drive, its volumes and
compose projects as folders, its log as a file so F3, the viewer's search and its encoding picker
all apply to it, and start/stop/restart in the same context menu as everything else. Synchronisation
gained the half that was missing: it says what it is doing while it does it and can be called off, it
remembers what a pair looked like last time so a deletion on one side is carried to the other rather
than undone, it writes down what each run did and can put it back, and the preset now restores the
view of a comparison as well as its rules. Underneath both, the copy engine stopped losing things
at the edges — a symlink is copied as a symlink, an interrupted overwrite leaves the existing file
untouched, and verify-after-copy reads half as much as it did.
The rest came from use: the terminal tab's ✕ sits inside the tab and asks before closing, "Search in"
stopped clipping its own letters, Escape closes the synchronise window, and the search dialog's third
field remembers the folders you searched.
Added
-
A container's log is a file now. Every container's root holds
docker-logs.txt, and it is not
a file in the container — reading it asks the engine for the log. The point of making it a file
rather than a window is everything that then comes for free: F3 opens it in the viewer, so the
viewer's search, its jump to a line and its choice of encoding all apply. The Show Logs item is
still there for a quick look that does not move you out of where you were.A container that genuinely ships a file of that name shows its own instead — hiding a real file
would be the worse trade — and nothing can be written to the virtual one, because a copy onto that
row would create the real file that then shadows it. -
The Docker provider has a settings page, and containers can be started and stopped from the
panel. Everything except the engine and the exec fallback used to be reachable only by editing
docker.iniby hand — how much of a directory is worth reading before giving up, the image the
throwaway container is made from, whether the digest-named volumes are listed. It is a page in
Configuration ▸ Settings ▸ Docker now, beside the app's own; the file is still there and still
read, for setting a machine up from a script.Start, Stop, Restart, Pause and Unpause are in the same context submenu as the rest. They
change the container rather than reading it, so they ask first — and Start is the way out of the
provider's own two refusals, since deleting and renaming inside a container need it to be running.
"It is already like that" is treated as success and not as a refusal, which is what the engine
means by the 304 it answers in that case.The page shipped with a layout defect that neither a screenshot nor the Auto Layout conflict count
could see, and measuring it found it before anybody else did: with its rows pinned only at the top,
the page was handed 702 by 0 points while every control inside it still had a sensible frame.
It reports its own measurements to the regression suite now, because the two ways the app has of
measuring a window both stop shallower than a plugin's page sits. -
A container or a volume has a context menu now, and building it found two things the host had
been getting wrong since before this plugin existed. Right-click inside a Docker drive and the
Docker submenu offers Inspect (everything the engine knows, as formatted JSON in a window
you can scroll), Show Logs, Show Mounts, Copy ID — the full id, not the twelve
characters the column shows, because this is for pasting into adockercommand — Jump to
Volume, and Open Compose Project. Jump to Volume is the other half of the Mount column: the
column names the volume a directory really is, and this takes you to it.The items know which drive you are in.
panelScheme— which filesystem the active panel is
showing — has been documented in the plugin ABI since it was written and was set by nothing, so
until now a plugin could only gate a context item on the cursor's path, and a folder of your own
can carry the same path. Every file-system plugin gets this, not only the one that found it
missing.And the panel's context menu now sees the same context as everything else. It was evaluating
itswhenexpressions against its own five keys and never reaching the ones the host adds, so an
item gated on the panel's directory, the other panel's directory, or which side is active silently
never appeared — while the identical expression worked in a menu and on a keyboard shortcut.A plugin can also navigate inside its own drive now. The two "open this path" services checked
whether the path existed on the local disk, so anything inside a mount failed the check and the
call did nothing at all, without a word. The question goes to the filesystem the panel is actually
on. -
Docker containers and volumes are a drive. A new plugin mounts a Docker engine in a panel:
Compose Projectsgrouped by project and service,Standalone Containers, andVolumes— and
below those, a container's real filesystem, where F3, F4, F5 and F7 work as they do anywhere else.
It ships switched off, because a connection to a Docker daemon carries the same rights on the
machine as the person running the app; turn it on under Configuration ▸ Plugins…. Compose grouping
comes from the labels Compose puts on its own containers, so it is right for a stack whose
docker-compose.ymlis long gone, and a service running one container is that container rather
than a folder holding one. The engine is reached the way thedockercommand reaches it —
DOCKER_HOST, then the currentdocker context, then the usual sockets of Docker Desktop, Colima,
Rancher Desktop, Lima and Podman, the last of which works because it serves the same API.Stopped containers are in it, not just running ones. Docker's file API answers for a container
that has not run for a month, which is what makes this a drive rather than a process list. Two
things genuinely cannot be done without a running container — deleting and renaming, for which the
Engine API has no operation at all — and those are refused with a clear reason instead of faked.Volumes are drives in their own right, not only folders inside a container, because a volume
outlives the container that made it and is usually where the data actually is. One that nothing
currently mounts is still browsable: the plugin makes a throwaway container around it, never starts
it, and removes it when you leave. Inside a container, a directory that is really a mount says so
in the new Mount column and names the volume, so there is a way from the data back to the drive
it lives on. Status, Access (RW/RO/VOL/BIND/TMP), Image and ID are columns too.Three things it does not do, deliberately: it stores no credential, it never touches Docker's own
directories on the disk, and it does not run anything asrootto get past permissions the image
set — a delete the container's own user may not perform is reported as what it is. Nothing depends
onsh,ls,catortarbeing present in an image either; the archive API is the foundation
and runninglsis a fallback for the one directory it cannot serve cheaply, which can be switched
off entirely.A plugin can now also say that an entry is a symbolic link.
PfxFindDatahas a name, a size, a
time, anisDirflag and a mode, and appending a field to it would break every plugin built
against the older header — so the type bits of the mode are read instead, and a plugin that sets
S_IFLNKgetslin the Attr column. Reading such a file reads what it points at, rather than
copying out the zero-byte link and calling it an empty file. -
You can look at one side of a synchronisation row. Comparing was the only thing a row offered,
and comparing needs two sides — so every row that exists on one side only, which is every row of a
first backup, had nothing behind it at all: the one entry there beeped. A row's menu now also has
View Left and View Right, which open that side in the app's own viewer, and entries the row
cannot support are greyed out instead of silently doing nothing. A file inside a.zipis unpacked
and one on a server downloaded into a read-only temporary copy first, so the original is never
touched — and the copy is keyed by the archive's own timestamp and size, which means looking at the
same file twice reuses one copy while a rewritten archive can never serve a stale one. The viewer
is opened through the main window, so it is the viewer F3 gives you: the lister plugins, the
per-extension viewer application and the history entry all apply.Comparing works across those sides too. It needed two folders on this Mac, so a row inside a
zip or on a server beeped — the same answer looking at one side used to give. Both sides are
fetched first now, the two columns are named after the sides rather than after two files with
the same name, and a side that is a copy is handed over as not editable: merging into it and
saving would have reported success while the archive or the server kept what it had. -
The compare window says when there is nothing to see. “No differences” was there all along — in
11 pt secondary grey, at the very bottom edge of a window whose whole middle is two columns of
identical text. That is a sentence you have to go looking for to answer the one question the window
was opened to answer, and it was reported as the window saying nothing at all. Two identical files
now put a tinted band across the top, in the text comparison and in the hex one, and it appears
only in that case — so its presence is the answer. -
You can see what the app remembers, and make it forget. Two-way synchronisation works because
it keeps a record of what each pair of folders last agreed on, and a record that no longer fits its
folders is the one input that turns into deletions nobody asked for. There were already guards
against acting on a bad one; there was no way to look at it. Memory… in the synchronise window
lists every pair that is remembered, marks the one on screen, says when it was last run and how
many paths it covers, and lets any of them be forgotten — after which the next comparison of those
folders behaves like a first one: it copies differences, deletes nothing, and starts remembering
again. An unreadable record is listed rather than hidden, because it is the one somebody most needs
to be able to get rid of. Nothing is ever forgotten automatically: a folder on an unmounted disk is
not gone, only unplugged, and discarding its history would leave the pair silently unable to carry
a deletion across the next time it is connected. -
A third synchronisation mode that can carry a deletion across. The two existing modes cannot
tell one thing apart: a file present on one side only is either new here or deleted there, and
those look identical. So the symmetric mode copies it — delete something on your laptop,
synchronise, and it comes back from the backup — while the mirror mode deletes, but in one
direction only, so anything done on the target is lost. Two-way (remember) keeps a record of
what both folders looked like when they last agreed, and with that a deletion on one side can be
carried to the other.The first run of a pair has no record, so it behaves exactly as before and deletes nothing; it
writes the record, and the mode works from the second run on. A deletion carried over has its own
colour and glyph and is not ticked — it is the only row that comes from the app's memory rather
than from anything visible in the two folders, so it is armed by hand, and clicking its arrow
offers the other answers: copy the file back instead, or leave both sides alone. Changed on one
side and deleted on the other is a conflict, never a deletion; so is a file changed on both.
Nothing is deleted on the strength of an absence the comparison could not confirm — an unreadable
folder, or one the filter held back, proves nothing about what is inside it. And a run that would
delete more than half of the paths the last run knew is refused rather than offered.Two folders on this Mac only. A deletion inside an archive rewrites it and a deletion on a server
is permanent, and neither is something to try with a mode whose purpose is deleting on both sides.
There is no undo for a deletion anywhere in the app; on this Mac the Trash is the whole safety net,
and the help page says so. -
An advanced filter for the directory synchronisation. The mask field takes one include list over
file names, which cannot saynode_modules/, cannot say "nothing over 2 GB", and cannot express an
exclusion at all. A Filter… button beside it — the one control this adds to the window — opens a
sheet with the same three tabs the advanced search has. Exclusion patterns work on the relative
path: a name without a slash matches at any depth, a trailing slash means a folder and everything in
it, and a pattern containing a slash matches the path, with*stopping at a separator. Size and
date judge a pair as a whole, because applied to one side an exclusion would make the pair look
one-sided and turn into a copy in the wrong direction. "Within the last N days" is stored relative
and measured from each run, so a saved job keeps meaning the last month. The Plugins tab asks a
content plugin about the side a file is copied from, once per file, and is offered only when both
sides are folders on this Mac — the registry needs a real file, and answering "does not satisfy" for
a side that cannot be asked would drop the whole comparison.A filter that cannot be seen is how a backup ends up incomplete while the window reports success, so
the button title carries how many criteria are set, loading a preset that carries a filter moves that
title too, and the status line says how many entries were held back next to what the run will do.
Filters travel in the existing sync presets. An excluded folder is not deleted in mirror mode either. -
A recursive permission change can be watched and stopped. Over a home folder that walk runs for
minutes, and it was the one operation in the app with nothing to see and no way out. It goes through
the transfer queue now — the same progress window, Stop button and pause the copies get — with the
total counted up front the way a delete counts one. Cancelling returns the counts as they stand
rather than unwinding: a chmod cannot be taken back. -
The comparison tolerance is on screen.
SyncOptionshas carried it since the day it was
written and nothing could set it: two seconds, for everyone, for ever. Two is right for FAT, which
stores timestamps to that precision, and wrong for a share whose clock is a few seconds off its
client — an ordinary thing for a share to be, and there the whole tree reads as changed. Loading a
preset now also restores Ignore a 1-hour difference and Case sensitive, which the preset store
had been round-tripping all along while the window quietly dropped back to the defaults. -
Synchronize Directories says what it is doing while it does it. Comparing two large trees took
minutes during which the window showed the word "Comparing…" and nothing else — no count, no
spinner, no way to call it off; the only signal that the app was alive rather than wedged was that
it had not been force-quit yet. The scan now reports as it goes: the entries walked on each side,
then the files compared asdone/total, with a spinner beside the count. The Compare button reads
Stop for as long as the scan runs and cancels it, and a cancelled scan leaves the previous
result standing rather than showing the half of the tree it had reached — a partial tree would have
classified every file it never saw as "only on the other side" and offered it for copying. -
The result grid now shows the dates, and can be ordered and asked about. It showed two sizes
and an arrow, so the one thing that decides that arrow in the default mode — which side is newer —
could not be seen at all. Both timestamps are columns now; every column sorts by clicking its
header; a double-click (or Compare in the row's menu) opens the two sides in the text
comparison, and Reveal in Finder shows the file the row is about. -
A conflict can be given a direction. When two files are the same age and differ anyway, the
classifier refuses to guess — and until now that refusal was final: the row could not be ticked, so
the pair could never be synchronized from this window at all. Clicking the ≠ now cycles it
through →, ← and back, ticking the row when it points somewhere and unticking it when it
goes back to being a conflict. The counts under the grid follow what the row says now rather than
what the scan first decided, so a resolved conflict is counted in the direction it was given. -
Synchronizing reports its progress and can be stopped, the way comparing already does: the
Synchronize button reads Stop while it runs, the status line counts the items, and stopping
between items leaves what was copied copied. Closing the window stops whichever of the two is
running instead of leaving it to finish into a window nobody can see. -
Swap sides, which is also how the right-hand tree becomes the master: mirroring only ever runs
left → right, so "the right side is the one that wins" previously had no way to be said. It is
worth having on its own — the panels decide which folder lands on which side, and they are often
the wrong way round. -
Two comparison options that existed in the model and nowhere on screen. Ignore a 1-hour
difference absorbs the FAT/daylight-saving offset that makes an unchanged file look an hour old;
Case sensitive decides whether two names differing only in case are the same file. The options
now sit on two rows rather than one, so the window's minimum width does not grow with them. -
A direction filter over the compared result, and it drives the selection. Show narrows the
grid to everything, to the rows whose change lands on the right (→), or to those that land on
the left (←); Hide identical leaves out the files that are already the same on both sides,
which until now could not be got out of the way at all. Select All then ticks exactly what is
on screen and unticks everything the filter hides, so choosing a direction and pressing it is a
one-way run in two clicks; Deselect All clears every row, shown or not, for picking by hand.
Reversing a row's direction moves it out of a filter it no longer belongs to instead of sitting
there pointing the wrong way, and the status line — which counts what Synchronize will actually do,
filtered or not — says how many rows the filter is holding back.
Changed
-
A sync preset carries what the grid shows, and the window opens on the last one used. The
preset row saved the comparison and not the view of it: the direction filter and Hide
identical were left out on the grounds that they narrow the result rather than the comparison.
That is the wrong line to draw — what a preset is for is "show me this pair of folders the way I
always look at them", and half of that is which rows are on screen, so every load ended with the
same two controls being set again by hand. They round-trip now, are applied to the rows already on
screen, and the window comes back to the preset most recently chosen or saved instead of to
(none).An installation that has never saved one gets a Default preset the first time the window opens,
so the popup is no longer an empty control that appears to do nothing and there is something to
save over — the shortest way to make a preset is to change two things and overwrite one that
exists. It holds the window's own defaults and nothing cleverer, so the first run of the window
behaves exactly as it always did; deleting it makes it stay gone, because it is seeded from the
absence of the file rather than from an empty list. -
Escape closes the Synchronize Directories window, which had no keyboard way out at all: Return
compares, and everything else was the red button. While a scan or a synchronization is running it
is the Stop button instead, and the second press closes — pressing Escape to get out of a dialog
and closing the window mid-copy is the one outcome worth designing around. -
"Search in" clipped the top and bottom of its own letters, and is a combo box now. All three
entry controls in that dialog are 24 pt with a 16 pt text rect, and 13 pt system text needs exactly
16 — no slack at all — so where the line sits inside that rect decides whether an "Ä" keeps its
dots. A bareNSTextFieldsets it lower than theNSComboBoxbeside it (first baseline 17 pt
against 13), and on this one field the tails of "g" and "j" met the bottom edge while the umlauts
met the top. It showed here and nowhere else because this is the field that always holds a real
path — "Search for" holds*.*and "Find text" is usually empty — and it was reported from the
Midnight palette, where light glyphs on a dark fill make the contact obvious.Made the same control as its two neighbours rather than nudged by a point or two: two controls that
have to line up exactly, drawn by different AppKit classes, is the arrangement that produced this.
Giving the field more height was measured and rejected — its text rect grows with it and the combo
boxes' does not, so the box ends up 2 pt taller than the ones it sits between.It gains the dropdown the other two have had since F-406: the last 20 folders searched, most
recently used first, cleared by the same Clear History…. A line break now counts as a folder
separator alongside;as well, so a pasted list works. And Spotlight mode was handed the raw
field all along and never split it at all:a;bsearched a folder calleda;band found nothing. -
Closing a terminal tab always asks, and the ✕ is inside the tab. The question used to be
reserved for a tab with something running in it; but the tab is also where everything the shell has
printed lives, and the ✕ sat beside the tab rather than in it — two bezels a point apart, with the
next tab immediately to its right, which is what it looked like it would close. The ✕ is now drawn
inside the tab's own bezel, sized and coloured to the name beside it, and a tab with a job still
names the job, because that is the answer that decides it. -
Verify after copy reads half as much. It read both files again once the copy was done and
threw both checksums away, so a verified copy across volumes moved three gigabytes of reads where
two would do. The copy now works out the source's checksum while it is reading the bytes it is
copying — one pass over data already in a buffer — and the verification only has to read what
actually landed, which is the point of verifying anyway. A same-volume copy on APFS is unaffected:
it is a clone, which never reads the bytes at all, and giving that up to obtain a checksum would
make the whole operation slower. Those sources are read a second time exactly as before. -
Base64 encoding and decoding stream. They read the file whole, built a string a third larger
again and copied that to bytes — several times the file's size in memory at once, one keystroke away
in the panel. Measured on a 200 MB file: 744 MB resident before, 207 MB after, and about 207 MB of
that is file cache in both runs, so the difference of roughly half a gigabyte is what was actually
being held. The output is byte-identical, which is what the new encoder's test is for: every length
from 0 to 400, in five chunk sizes, against the whole-buffer result.decodeAutodecides the scheme
from the first 8 KB and then streams; uuencode and xxencode still read whole, deliberately — their
frame carries a length byte per line inside abegin/endenvelope, and they are legacy formats
used on small payloads.
Fixed
-
Maximising the search window enlarges the hit list. Both heights in that dialog were lower
bounds, which leaves the extra height of a resized window for the layout engine to place — and it
gave all of it to the criteria tab. Measured at a content height of 1100 pt: the tab grew from 426
to 856 pt with its own criteria still 302 pt tall and the rest of it empty, while the results list
stayed on the 160 pt floor it is built with. So the one thing a bigger window was wanted for was the
one thing it did not buy. The tab is held at its measured height as a preference now, which leaves
the list as the only view that can take the room; the required lower bound still wins whenever the
criteria need more than the measurement predicted, so nothing can be clipped. The default window
gains rows too — 250 pt of list where there were 160. -
The rename prompts show where in the name you are. Shift+F5 comes up with the source's whole
path in the field, and it used to arrive with all of it selected: AppKit draws no insertion point
while a selection stands, so a 128-character path was one highlighted bar with no cursor anywhere in
it — and the field scrolls to the start of a selection, so the name on the end, the one part worth
editing, was off screen. Only the name is highlighted now, without its extension, which is what the
in-cell rename in the panel has always done; the path in front of it stays put, stays visible, and
survives the first keystroke. The Rename and New Text File prompts do the same, and the rule they
share is one piece of code rather than three. -
Two files that could not be read are no longer reported identical — or as differing. Both
comparison windows fall back to a placeholder for a side they cannot read: the text one to a line
carrying the file's name and size, the hex one to an empty byte source. Two empty byte sources are
“identical (0 bytes)”, and two placeholder lines are equal whenever the names and sizes coincide —
so a pair of files neither of which had been opened came out as proof that they are the same. And
when the placeholders differed — two 300 MB files, which is the ordinary case — the text window
reported “1 differing line block”, a difference between two files it had not read either. Whether
the sides were read now decides what may be said about them, before any count does: both windows
say that nothing was compared, in the warning colour, and say which file it was when only one side
failed. The hex window's “identical” sentence is also no longer English in the other eighteen
languages — nor are the three sentences beside it. None of the four ever went through the string
extraction, which nothing noticed while they were an 11 pt line at the bottom of the window; all
four are translated now. -
The “this cannot be undone” warning covers the cases that actually cannot. It asked only
whether a side was a server, so it said nothing about two deletions that are just as final: one
inside an archive, which is a whole-file rewrite with no Trash to fish anything out of, and one in
a folder on a network volume, which is an ordinary local path as far as the comparison is concerned
and where the Trash generally refuses. And the sentence itself said “deletions on the server”, which
it was also saying for an archive — right about the consequence, wrong about the reason, which is
how somebody ends up checking the wrong path field. -
“Done — trees synchronized.” now means it. The synchronisation run answered a list of errors
and nothing else, so a successful item left no trace and success was only inferable as “absent from
the error list”. That inference did not hold: a cancelled run returned the same list as a finished
one, a cancellation in the copy loop skipped the batched archive rewrite so files staged for a zip
were neither written nor mentioned, a failed rewrite was one error with an empty path however many
entries were in the batch, and the mirror's deliberate “kept it, something inside was not part of
this comparison” sat in the same list as an I/O failure — which made an ordinarynode_modules/
exclusion turn a wholly successful run into “Completed with 3 error(s)”. Every planned item now
says what became of it, the archive rewrite always runs and names each entry it staged, a
cancelled run reports how much it managed, and a failure carries the relative path instead of just
the file's name. -
The run's verdict is readable. A run is followed at once by an automatic re-comparison, and
that overwrote the status line before anybody could read what had happened. The verdict stays until
the next run. -
A mirror whose source cannot be read no longer offers to empty the target. Point the left side
at a folder that is not there — a typo in the path field, an unmounted volume, a permission failure
— and the comparison came back empty, which in mirror mode means "delete every file on the right":
twelve rows, pre-ticked, one confirmation click away. Measured on a mistyped path. A plan whose
deletions cannot be justified is now refused before it is offered: Synchronize is disabled, and the
status line says which side could not be read instead of showing a count of deletions. The rows
themselves stay visible — the comparison did produce them, and hiding that would be its own kind of
lie. Two more refusals come with it: a plan that would delete more than half of the known entries
(never for fewer than ten, so ordinary housekeeping is untouched), and a comparison of a folder
against itself or against something inside it, which had no check at all where a single copy has
had one all along. -
The comparison now says what it was able to see. All three walks turned a failure into an empty
result: a root that is not there or cannot be read, an archive that will not open, and a server
whose root listing fails each produced "nothing here" with no error and no mark. That matters
because a deletion is derived from something being absent, and in mirror mode an empty result
on one side classifies every file on the other as "delete it". Each side of a scan now reports
whether its walk could be started, how many entries it was handed, which paths it held back, and
which folders it did not see to the end — and answers, per path, whether it can actually prove that
path is gone. Nothing acts on that yet; the refusal that uses it comes next, and this is the fact
it will be built on. -
uuencode and xxencode no longer read the whole file. They were the last two of the four
encodings still doing so, on the reasoning that the frame carries a length byte per line and these
are legacy formats used on small payloads. True, and not a size limit — the app offers them on
whatever is selected in the panel, with one keystroke. Both formats fix 45 payload bytes per line,
so whole lines can be written and forgotten; the output is byte-identical to what the whole-buffer
codec produced, which is still checked against the systemuuencode. Decoding is line-oriented for
the same reason, and now buffers only up to the next newline. -
“By content” across a server or an archive held both files in memory, and read them to the end
even when the first byte already differed. Two local folders were compared in 64 KB blocks all
along, stopping at the first difference; a server or a zip side was not — both files were read whole
and the two buffers compared, so a 3 GB pair meant six gigabytes resident and a full download for a
verdict that was already settled. All three kinds of side stream now, in one comparison rather than
two. Measured: with a difference in the first block, a server hands over one block instead of the
whole file. -
A pair neither side of which could be read was reported as identical. The comparison answered
leftData == rightDataover two optionals, and two absent values are equal — so a file the app had
not managed to open, on either side, came out as “equal” in the result grid and in the plan. It now
falls back to size and date, which is what a comparison that was not by content would have said:
“not compared” and “identical” are opposite statements, and only one of them was true. -
A mirrored synchronisation deleted files it had been told to leave out. Mirror mode removes a
folder that exists on the target side only, and that removal is recursive —fm.trashItemon a
folder takes everything under it, and the archive editor drops every entry beneath the path it is
given. Whatever the file mask or Ignore hidden held back was under that folder and had never been
a row in the plan, so it went too, after the window had shown the user that it was not part of the
comparison. Measured on all three kinds of side: mask*.txt, mirror mode, a target-only folder
holding one.txtand one.jpg, and the.jpgwas in the Trash; the same with a dotfile and
Ignore hidden; the same inside a zip. A mirror may now delete only what it actually compared: the
scan marks a folder that still holds something it left out — by the mask, by Ignore hidden, or
because With subdirs was off and it never looked inside — and such a folder is not planned for
deletion at all. The executor refuses one anyway as a last line of defence, for a folder that
gained a file between the comparison and the run, and reports what it kept. A folder whose whole
content was compared is still removed, so an ordinary mirror run is unchanged. -
A saved sync preset could take every other preset with it.
SyncPresetandSyncOptionsused
the synthesizedCodable, which does not fall back to a property's default for a missing key — it
throws — andSyncPresetStore.loadanswers[]for anything it cannot decode. So the next option
added to either type would have made every existingsync-presets.jsonfail to load, and the save
after that would have written the empty list over the file. Both types decode field by field now,
the waySearchTemplatehas since the same trap was found there, andupsertrefuses to write over
a file that exists, is not empty and does not parse. The date strategy is pinned to ISO-8601 on both
sides while the format still carries no date, so the first one to arrive needs no migration. -
A preset forgot whether hidden files were part of the comparison. Ignore hidden was in no
preset and in no config file — it reached the engine as a bare argument — so a comparison saved
without hidden files came back with them.
The last of the paths that touch data: the attribute, checksum and encode/decode engines, plus
MkDirEngine, which came out clean. Three measured, two read.
-
A recursive permission change reaches what it says it reaches. The attributes were applied to
the folder first, so a mode without its execute bit —0400is an ordinary thing to ask for —
made the listing that came next impossible and the descent found nothing. Measured over a folder
with a file in a subfolder: the top folder changed, everything below untouched, reported as "1
changed, 0 failed". Children first now, and a walk that could not be finished is counted as a
failure rather than swallowed by acatchwhose comment claimed the counts reported it. -
A checksum file can no longer have a line checked against a file outside its folder. The names
come out of the file, and aSHA256SUMSarrives with whatever it describes; a line naming
../somethingwas hashed and reported ok, so a download could be declared intact on the
strength of a file that was never part of it. Descending is still allowed — the format legitimately
listssub/file.txt— but leaving the folder is not, and such a line now has its own status in the
report rather than being counted as missing. -
Decoding asks before replacing. The command drops a known encoded extension, so
report.pdf.b64
targetsreport.pdf— the file it was made from, still sitting next to it. Replacing an existing
file was therefore the ordinary outcome of this command, and it happened without a word. The
encode direction never had the problem: it goes through a save panel, which asks. -
A name out of a listing cannot put a permission change outside the tree. For a server or a
plugin mount the names are whatever the far side sent; only.and..were filtered, so a name
with a separator in it reached outside the selection. Covered by a filesystem whose listing offers
one, becauseLocalFSnever will and an untested guard is a line nobody has run. -
A file that could not be read is named in the checksum list — as a comment, which every one of
these formats skips — rather than silently left out of it. A checksum file one line shorter than
the selection, with nothing to say which line went missing, is the one kind of vagueness this
format cannot afford. -
A change that arrives while a dialog is open is no longer forgotten. The directory watcher
refuses to re-list the panel while a modal dialog or an in-cell rename is up, which is right — the
question a dialog is asking is about the listing as it was when it opened. What it did with the
event was to drop it:continue, no note taken, and nothing that ever looked again. A file that
landed in the panel's folder in the moment a dialog happened to be open therefore stayed invisible
until somebody reloaded by hand, and nothing on screen said so. The refresh is now kept and run as
soon as the way is clear. Measured both ways in the real app: with the old code the file never
appeared; with this one it appears the moment the dialog closes, and not before — the guard still
does its job. Covered by a VM scenario with both halves asserted.
The next round of the same review, over the paths that run as administrator and the two engines that
rename and split files. Eight findings; four measured, and one suspicion that turned out to be wrong
and is recorded as such below.
-
A
.crcsidecar can no longer name where the file is written.combinetookfilenamefrom
the sidecar's text and joined it to the output folder, and.crcis a Total Commander format —
these files arrive with the parts, from wherever the parts came from. A value of../…therefore
put the reassembled file outside the folder the user chose: measured, it landed in the parent
directory. The parser now applies the rule the archive and server paths already apply to a name
that comes from elsewhere: anything but a plain component is not a sidecar this app acts on. -
Splitting and combining ask before writing over something. They were the one write path in the
app with no conflict question at all: combining onto a name that was taken destroyed the file that
had it — measured — and splitting wrote over parts of the same name. Both now report it and the
window asks, with the same Overwrite / Cancel choice as everywhere else. -
A set of parts that does not add up is refused before anything is written. The old loop stopped
at the first part it could not open, so a missing.003with.004present became a file of the
right name and the wrong content, left on disk with only the CRC afterwards to say so. The sidecar
records the original size, so the question has an answer up front — which also catches a part that
arrived truncated. -
A failed batch rename no longer leaves a file where nobody can see it. Every rename stages
through a temporary name; when the final move failed, the recovery was one attempt at the old name,
and that name can legitimately be taken by another rename in the same batch that did succeed.a → btogether withb → cover an occupiedctherefore left the second file parked as
.pcren-<uuid>— invisible in the panel, indistinguishable from deleted, and exactly what the
comment beside that line said must not happen. It now walks the app's own auto-rename names until
one is free and reports where the file ended up. Undo had the same hole. -
"Retry as administrator" is offered when the source is the unreadable one. The offer appeared
only for a destination folder this user cannot write to, ruled out for everything else by a comment
claiming root cannot help read an unreadable file — which is the wrong way round. Copying a
root-owned 0600 file out of a system folder into your own home is a permission failure with a
writable destination, and it was the case that would have worked. -
The privileged command cannot replace anything.
mv -fandcp -Rpbecamemv -nand
cp -Rpn. The destinations are established to be missing before the password dialog;-nmoves
that guarantee to the tool at the moment it acts, on the one command line in this app that runs as
root. -
The parts are looked for next to the sidecar, not in the output folder, which made combining
into any other directory impossible — it reported "no parts" — and is the wrong question anyway:
the sidecar describes the set it sits in. -
A part size of zero is an error, not a crash. It was a
precondition, which takes the process
down; the dialog checks first, so only a plugin or a script could reach it.
Not a defect, recorded because the investigation of it was published as one: a file name containing a
line break was suspected of being corrupted on its way through the AppleScript layer to the root
shell. It is not — do shell script passes the byte through unchanged. What converts a line feed to a
carriage return is the string AppleScript hands back, so the first measurement had read the way out
and called it the way in. ShellQuoteTests now pins the real question by having the shell write to a
file, and PrivilegedRunner is unchanged.
Six of these were found by reading the copy, move and delete engines and then measuring the
suspect rather than asserting it. Three lost data outright.
-
A move no longer deletes a source it did not copy. On the cross-volume and folder-merge path,
MoveEnginecopied and then deleted, with a comment reading "Only delete the source after a
successful copy" above code that never looked at what the copy reported.CopyEngine.rundoes not
throw when the resolver answers "skip" to a failure — it returns a shorter list — so a copy that
never happened was followed by a delete that did: measured, the file was gone from the source and
had never reached the destination. It now also asks whether anything inside the item was skipped,
because a folder whose child was skipped still counts as copied and deleting the tree would have
taken that child with it. Where anything was left behind the whole source stays, which leaves
duplicates a user can see instead of a hole they cannot. -
A rename onto the source's own name no longer truncates it.
.overwriteand.appendboth
refused to arrive at the same file;.renamedid not, so a rename that keeps the name in the
source's own directory opened the file for reading and truncated it for writing. Measured: "the
only copy" became 0 bytes. This is the fault F-399 fixed for the other two doors. -
A cancelled overwrite no longer destroys the file it was replacing. The target was removed
before the write, and the failure path unlinked the partial new file, so a cancel in between
left neither. Measured with a throttled copy and a Stop after 400 ms: the target was simply gone.
The copy is now written to a sibling name and takes the target's place withrename(2)as its last
step — either the replacement happened or nothing did. Symlink targets are replaced the same way. -
Appending a file onto itself is refused on the path a move uses.
copyRegularFilehas always
refused it ("reads what it is writing: it does not converge");appendRegularFile, the entry
MoveEnginecalls for an append, had no such check — and the failure mode is to fill the volume.
Not measured, for that reason. -
Choosing another name no longer overwrites whatever has it. When the renamed-to name was taken
too, the copy wrote over it and the move letrename(2)replace it, without anyone being asked —
the opposite of what choosing a new name is for. Both now ask again, which is what
OverwriteRules.autoRenameNamehas documented all along ("a further conflict simply re-prompts"). -
A rename decision on a folder target now creates the folder. With a file in the way and the
resolver answering "rename", the code did nothing at all: the file stayed, no directory was made
because the path "existed", and the children were then copied to paths underneath a regular file. -
A delete can be asked about, like a copy. One locked item threw on the spot, abandoning the
rest of the selection and reporting nothing about what had already gone — under a doc comment
promising the successful paths.DeleteEnginenow consults the same resolver copy and move do
(retry / skip / abort), the transfer queue passes it through, and a folder whose contents could not
all go is no longer reported as removed. The default answer is still to abort, so nothing that
already called it changes behaviour. -
The progress total counts a followed symlink by what it points at. With
followSymlinksthe
copy takes the link's target, which may be a whole directory, while the plan counted the link as
one file — so the bar filled past its own end. A link pointing at itself is now refused instead of
recursing until the stack runs out. -
Case sensitivewas declared, saved in presets, and never read. Matching was always exact, so
on a case-insensitive volume — which is what macOS formats by default —README.mdandreadme.md
showed up as two rows, each "only on one side", and synchronizing them copied each onto the other:
on such a volume, the same file twice. The option is now honoured, each side is read by the name
that side actually has, and the row is named after the left side's spelling. -
An empty folder on one side was never synchronized. Directories were left to be created by the
files moving into them, which works for every folder except one with nothing in it — so an empty
folder was a difference the two trees kept for ever. A folder that exists on one side only is now
copied like a file. -
The list of what went wrong is shown, not just how many. A failed run reported "Completed with
N error(s)" and threw away the per-item reasons the executor had produced. They now open in the
Operation Errors window, which already existed for exactly this. It opens as its own window rather
than as a sheet on the sync window: as a sheet it sat on the application's terminate path, and a
run that ended with it up would not quit. -
The confirmation warns before deleting on a server. Deleting locally goes to the Trash;
a server has no Trash, and the code that deletes has claimed since it was written that "the dialog
says so before the actions run" — of no dialog that existed. -
A second Synchronize window no longer orphans the first. The app kept exactly one strong
reference, so opening a second window released the first one's controller while its window stayed
on screen. Each window is now held until it closes. -
A file extracted from an archive keeps the entry's timestamp rather than the moment of
extraction, which the next comparison read as "newer than the archive" and offered to put back. -
“By content” compared the wrong bytes when one side was a server. The branch that reads the two
files asked "is either side a zip?" instead of "are both sides local folders", so an FTP or SFTP
side went down the local path:SyncSide.pathis the path on the server, and it was opened as a
file on this machine. Nothing was found, so every same-sized file was reported as different and
offered for re-upload — and where such a path did happen to exist here, it silently compared that
file instead. Measured on two byte-identical files with a server side:contentEqual = false,
classifiedcopyToRight. It now reads the server's own bytes, and the test that pins it uses a
filesystem whose base path exists nowhere on disk — theLocalFSstand-in the other remote tests
use cannot catch this class, because its paths are local paths, so the wrong branch read the
right bytes by accident. -
Synchronizing onto a server no longer asks to undo itself on the next run. The upload wrote
bytes and left the timestamp behind, so the copy on the far side was newer than the file it came
from; the very next comparison answeredcopyToLeftfor the file just uploaded, and the one after
thatcopyToRightagain — one round of pointless traffic per run, for ever. The source's own
timestamp now travels with the bytes in both directions. Where the protocol cannot carry it — plain
FTP has no standard way to set a timestamp and refuses — the write is still counted as the success
it was rather than reported as an error, and “Ignore date” or “By content” remains the way to sync
such a server without churn. -
The Synchronize Directories window follows its own size again. Dragging it larger, or
maximizing it, moved the frame and nothing inside it: the result grid stayed the 780×300 points it
had been built with and the two path fields the 700 they had been built with, so the space a bigger
window buys — the whole reason to make this window bigger — went into an empty band under the
status line. Measured at a content size of 1400×900 the grid was still 780×300; it is now
1376×706, the paths span the window, and the extra width goes to the Name column, which is the
column whose content does not fit. The window also has a floor now, measured from the row of option
checkboxes rather than typed in, so it holds in every language rather than only in English. Nothing
was logged while this was wrong — the layout was satisfiable and merely wrong, which is the class
of layout defect a constraint-conflict count cannot see. -
The stray magnifier on the viewer's scroll bar is gone. A small magnifying glass sat on the
vertical scroll bar of the viewer and of the hex editor, doing nothing when clicked and covering
the bar it sat on. It was the filter field of the closed strings panel: closed means zero points
wide, not gone, and the field is inset six points from both edges, so AppKit went on painting the
field's own magnifier glyph outside a panel of no width — which in these two windows is pinned to
the right edge, exactly where the scroll bar is. Both side panels now clip to themselves, so a
closed one draws nothing.