Skip to content

refactor(export): UI/UX, native and HIG audit of the import and export surface - #2631

Merged
datlechin merged 2 commits into
mainfrom
refactor/import-export-uiux
Sep 3, 2026
Merged

refactor(export): UI/UX, native and HIG audit of the import and export surface#2631
datlechin merged 2 commits into
mainfrom
refactor/import-export-uiux

Conversation

@datlechin

Copy link
Copy Markdown
Member

A UI/UX, native-macOS and HIG audit of the whole import and export surface, and the fixes for what it found. Seven auditors covered the export tree, the import sheets, the transfer and server-side sheets, sheet and window conventions, keyboard and accessibility, progress and error reporting, and user-facing strings. Every finding was then handed to an adversarial reviewer told to refute it: 34 survived, 20 did not.

Nothing here changes PluginKit, so there is no ABI question.

The three that would have cost a user data or time

The transfer's destination Database picker was inert. It was bound to a value nothing else read. Rows landed in whichever database the destination connection happened to be parked on, and with Delete existing rows first ticked the DELETE landed there too. Both the column read and the write now go through a DatabaseScope built from the picker.

Oracle Data Pump interpolated names into PL/SQL literals unescaped. The Snowflake and BigQuery arms both take an escaping function; the Oracle one was never handed it. An Oracle identifier may legally hold a quote, and a raw one closes the outer literal early and hands the rest of the name to the parser. The NAME_EXPR and SCHEMA_EXPR filters sit inside two levels of quoting, so a quote is escaped twice. Fixing it also fixed the session-user fallback, which had been writing || USER || inside the literal, so it had never resolved to anything.

Restore Dump replayed into a database with no confirmation. Picking a database in the list was the last step before the first write, behind a button whose ellipsis promised another one.

Export object tree

  • Every row refused selection, so there was no keyboard navigation at all: no arrows, no Space, no Left/Right, and nothing for VoiceOver to move through. Rows are selectable and an NSOutlineView subclass routes Space to the same toggle a click performs.
  • Recycled cells swapped their hosting view's root without changing its identity, so a row inherited the previous object's @State. The row-scope popover offered the last table's columns and wrote them into this table's SELECT. Each row now carries .id(node.identity).
  • A single checkbox toggle reloaded every row in the database. Only the object's own row and the containers above it are reloaded now.
  • rowSizeStyle was .default, shorter than the content each row draws; the app's two other hosted outline views both name 24pt.
  • The loader computed isExpanded per container and nothing read it, so every database on the server opened at once.
  • A mixed object checkbox turned its row off while the identical control on the group above it turned everything on.
  • Checkboxes were unnamed to VoiceOver, and TristateCheckbox already took a label.
  • Row scope: Select None set the column set to empty, which this editor reads as every column, so the button did nothing. commit() also erased a stored subset whenever the column fetch came back empty.

Reporting

  • A finished transfer closed its sheet and reported nothing, warnings included.
  • A stopped import said nothing about the statements it had already committed.
  • A failed restore leaves the target part-way through the dump; only a cancelled one said so.
  • Oracle's Data Pump block ends in DETACH, so the statement returns before anything is written. It was reported as written.
  • A streaming query export drew a determinate bar at zero and an N/0 rows label for the whole run, and named itself (0/1).
  • A notification body carried raw multi-line stderr, head-truncated, so the sentence naming the cause fell below the cut.

Conventions

  • The transfer confirmation was hand-built, so Return sat on the destroying button and Escape was inferred from a title that stops matching once localized. AlertHelper exists for exactly that and documents both traps.
  • Stopping an import applied at once while stopping an export or a backup asked first.
  • Cancel sat at the leading edge of three footers, which is the slot macOS reserves for a button that is not part of the decision. Uses the repo's own DialogFooter.
  • Export, transfer, server-side export, row import and the result sheets were pinned to fixed frames. They resize; the export tree takes the extra room.
  • The backup save panel and the SqlPackage password warning followed NSApp.keyWindow, which during a sheet transition is not the flow's own window.
  • Server-side export could not be cancelled or escaped while it ran.
  • Field detection parsed the whole file on the main actor, so the sheet's own spinner could not draw a frame.
  • RowImportSheet never snapshotted the plugin's persistent options, so Cancel kept them and Delete existing rows stayed armed for the next import from anywhere in the app.
  • Fifteen plugin option pickers passed an empty label; thirteen more controls across the import sheets did the same.
  • Parquet was in neither the format order nor the description table, so it sorted arbitrarily and showed no description. Both now live in ExportFormatCatalog, which falls back to a plugin's own description for a format it does not know, so a registry format installed after this build still lands somewhere sensible.

Refactors

  • sheetContent(for:) was 166 lines and over the SwiftLint limit. The eight transfer sheets moved to MainContentView+TransferSheets.swift.
  • Format ordering and descriptions left ExportDialog for ExportFormatCatalog.
  • Fake plurals (%d table(s), table\(n == 1 ? "" : "s")) replaced with explicit singular and plural keys, or SwiftUI's ^[](inflect:) where a Text can carry it. String(localized:) returns that markup verbatim, which OperationCompletionCopy already documents.

Verification

  • Build: PASS.
  • swiftlint --strict over TablePro, Plugins and TableProTests: 0 violations. Two pre-existing warnings on main (BackupDatabaseFlow line length, MainContentView function length) are gone as a side effect.
  • New tests: ExportFormatCatalogTests (5), plus 4 Oracle escaping cases in ServerSideExportTests. 24/24 in that group, 59/59 across the nine export and transfer suites.
  • The seven plugins whose option views changed each build. AllPlugins fails locally on the known oracle-nio @TaskLocal macro issue, unrelated to this change; CI covers it.
  • docs: house style and source claims agree.

Not covered: no UI automation for the export tree's new keyboard handling. The tree is an NSOutlineView behind an NSViewRepresentable and XCUITest reads its rows through the hosted SwiftUI, so a Space-toggles-the-selected-row test would assert on the checkbox's accessibility value rather than on the model. Worth doing, but it belongs with a broader pass over the export dialog's automation, which has none today.

https://claude.ai/code/session_011EqgjCjCAU6tiiVmnMpF86

…across the transfer sheets

Fixes the audit's confirmed findings in the import/export surface.

Export object tree
- Every row refused selection, so the tree had no keyboard navigation at all: no
  arrow keys, no Space, no Left/Right, and nothing for VoiceOver to move through.
  Rows are selectable now and an NSOutlineView subclass routes Space to the same
  toggle a click performs.
- Recycled cells swapped their hosting view's root without changing its identity,
  so a row inherited the previous object's @State: the row-scope popover offered
  the last table's columns and wrote them into this table's SELECT. Each row now
  carries .id(node.identity).
- A checkbox toggle reloaded every row in the database. Only the rows a change can
  redraw are reloaded now: the object's own row and the containers above it.
- rowSizeStyle was .default, shorter than the content each row draws. Matches the
  24pt the app's two other hosted outline views name.
- The loader computed isExpanded per container and nothing read it, so every
  database on the server opened at once.
- A mixed object checkbox turned its row off while the identical control on the
  group above it turned everything on.
- Checkboxes were unnamed to VoiceOver, and TristateCheckbox already took a label.

Transfer
- The destination Database picker was bound to a value nothing read: rows landed
  in whichever database the destination connection was last parked on. Both the
  column read and the write go through a scope built from the picker now.
- A finished transfer closed its sheet and reported nothing, warnings included.
- The confirmation was hand-built, so Return sat on the destroying button and
  Escape was inferred from a button title that stops matching once localized.
  Routed through AlertHelper, which exists for exactly that.
- A Stop pressed during the pre-transfer column read was cleared by the service's
  own reset before it could be seen.

Server-side export
- Oracle Data Pump interpolated the directory, table and schema names into PL/SQL
  literals with no escaping, while the Snowflake and BigQuery arms escaped theirs.
- The run could not be cancelled or escaped.
- The destination field was labelled with its own example value.

Elsewhere
- Restore Dump replayed into a database with no confirmation.
- Stopping an import applied at once while stopping an export asked first.
- A streaming query export drew a determinate bar at zero and "N/0 rows".
- Parquet was in neither the format order nor the description table, so it sorted
  arbitrarily and showed no description. Both now live in ExportFormatCatalog,
  which falls back to a plugin's own description for a format it does not know.
- Fake plurals ("%d table(s)") replaced with explicit keys or SwiftUI inflection.
- Cancel moved beside the action button, through the repo's own DialogFooter.
- Export, transfer and server-side export sheets resize.
- The backup flow's save panel and password warning followed NSApp.keyWindow.
- sheetContent(for:) split: the eight transfer sheets moved to their own file.

Claude-Session: https://claude.ai/code/session_011EqgjCjCAU6tiiVmnMpF86
…nd name every option control

The rest of the audit's confirmed findings.

Import
- The sheet edits the plugin's own persistent options in place and never snapshotted
  them, so Cancel kept every change and `Delete existing rows` stayed armed for the
  next import from anywhere in the app.
- Field detection reads and parses the whole file, on the main actor, so the sheet's
  own spinner could not draw a frame. Only the parse moves off it.
- A file the plugin cannot read is a failure, not an empty result: it showed the
  parser's message as grey placeholder text with nothing to press but Cancel.
- The column type chooser drew its own checkmark inside a Menu instead of using a
  Picker. The selection resolves case-insensitively so a differently-cased stored
  type still selects its own row.
- A stopped import said nothing about the statements it had already committed.

Backup and restore
- A failed restore leaves the target part-way through the dump. Only a cancelled one
  said so; the failure arm said nothing.
- The save panel and the SqlPackage password warning followed NSApp.keyWindow, which
  during a sheet transition is not this flow's own window.

Server-side export
- Oracle's Data Pump block ends in DETACH, so the statement returns before anything
  is written. Reporting it as written was wrong.

Strings
- Fifteen plugin option pickers passed an empty label, so VoiceOver read every one of
  them as an unnamed pop-up button.
- Parquet's codec names went through String(localized:). Snappy and Zstd are product
  names.
- Candidate binaries were joined with a localized " or " fragment, which gets the
  three-candidate case wrong in most locales. Uses .formatted(.list(type: .or)).
- A notification body carried raw multi-line stderr, head-truncated, so the sentence
  naming the cause fell below the cut. Flattened before it is cut.

Claude-Session: https://claude.ai/code/session_011EqgjCjCAU6tiiVmnMpF86
@mintlify

mintlify Bot commented Sep 3, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
TablePro 🟢 Ready View Preview Sep 3, 2026, 3:41 PM

💡 Tip: Enable Automations to automatically generate PRs for you.

@datlechin
datlechin merged commit ce7fd93 into main Sep 3, 2026
14 checks passed
@datlechin
datlechin deleted the refactor/import-export-uiux branch September 3, 2026 16:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant