Skip to content

v0.9.1

Choose a tag to compare

@github-actions github-actions released this 15 Sep 09:57
· 33 commits to main since this release

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.ini by 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 a docker command — 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
    its when expressions 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 Projects grouped by project and service, Standalone Containers, and Volumes — 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.yml is long gone, and a service running one container is that container rather
    than a folder holding one. The engine is reached the way the docker command reaches it —
    DOCKER_HOST, then the current docker 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 as root to get past permissions the image
    set — a delete the container's own user may not perform is reported as what it is. Nothing depends
    on sh, ls, cat or tar being present in an image either; the archive API is the foundation
    and running ls is 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. PfxFindData has a name, a size, a
    time, an isDir flag 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_IFLNK gets l in 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 .zip is 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 say node_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. SyncOptions has 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 as done/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 bare NSTextField sets it lower than the NSComboBox beside 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;b searched a folder called a;b and 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. decodeAuto decides 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 a begin/end envelope, 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 ordinary node_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 system uuencode. 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 == rightData over 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.trashItem on 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 .txt and one .jpg, and the .jpg was 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. SyncPreset and SyncOptions used
    the synthesized Codable, which does not fall back to a property's default for a missing key — it
    throws — and SyncPresetStore.load answers [] for anything it cannot decode. So the next option
    added to either type would have made every existing sync-presets.json fail to load, and the save
    after that would have written the empty list over the file. Both types decode field by field now,
    the way SearchTemplate has since the same trap was found there, and upsert refuses 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 — 0400 is 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 a catch whose 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 a SHA256SUMS arrives with whatever it describes; a line naming
    ../something was 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
    lists sub/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
    targets report.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, because LocalFS never 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 .crc sidecar can no longer name where the file is written. combine took filename from
    the sidecar's text and joined it to the output folder, and .crc is 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 .003 with .004 present 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 → b together with b → c over an occupied c therefore 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 -f and cp -Rp became mv -n and
    cp -Rpn. The destinations are established to be missing before the password dialog; -n moves
    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,
    MoveEngine copied 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.run does 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. .overwrite and .append both
    refused to arrive at the same file; .rename did 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 with rename(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. copyRegularFile has always
    refused it ("reads what it is writing: it does not converge"); appendRegularFile, the entry
    MoveEngine calls 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 let rename(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.autoRenameName has 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. DeleteEngine now 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 followSymlinks the
    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 sensitive was 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.md and readme.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.path is 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,
    classified copyToRight. It now reads the server's own bytes, and the test that pins it uses a
    filesystem whose base path exists nowhere on disk — the LocalFS stand-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 answered copyToLeft for the file just uploaded, and the one after
    that copyToRight again — 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.