Keep editing while a project saves; saves, reloads and opens queue behind one writer (12.13b)
This commit is contained in:
1 parent
838d5b1459
commit
d4c065fa77
11 files changed
+1027
-85
No files matched your search
@@ -49,7 +49,7 @@ Platform adaptations, each forced by a missing Qt analog:
|
||||
- Projects in the session: Swift's `projectURL` is `projectPath`, a `QString`, since files are paths here; it gets a setter when `ProjectController` needs one. Swift builds layers and the document through memberwise initialisers in two places (`installProject`, `applyDocumentSize`); the twin has two constructors for that, `ImageLayer(record, asset, mask)` and `CanvasDocument(snapshot)`, so both places agree by construction; shapes and text set them apart: `installProject` loads both after `CanvasDocument(snapshot)`, `applyDocumentSize` does not, as Swift's rebuild leaves them out. `applyImageSize` and `applyDocumentSize` live in `IO/ImageResizer.cpp`, as in Swift; they build the new document and record the step on a copy of the history before either is published (`DocumentHistory::adopt` takes the copy's steps: its past, its future and its revision, all a closed step changes), then fit the view and signal once, so memory that runs out on the way changes nothing (Swift begins, sets, fits and ends, signalling twice; since 10.1). The twin's document is read-only from outside: a test that needs a state no session call can make (a painted mask, a record with every field set) installs a hand-built `ProjectSnapshot`, where Swift's tests write into `session.document`.
|
||||
- Masks in the session (`LayerMask.cpp`, after the model): `isMaskSelected` has a getter only, since every Swift write of it sits in a session extension. `TransformEdit` gains `mask`. `commitTransform` hands a mask-alone edit to `commitMaskTransform`, which writes the placement inside one transaction and lets the history drop a change that changes nothing, where Swift compares placements first. In `copyMask` and `toggleMaskLink` Swift's `finishOpacityEdit()` after `commitTransform()` is left out: `commitTransform` has already finished the drag. `tests/SessionFixtures.h` holds `rewrite(session, change)`, which installs a changed snapshot where Swift's tests write a mask, a flip or a sampling into `session.document`; it resets the history as any install does, so steps are counted from there. `tests/SessionRecord.h` holds `described()`, the text of everything an observer can read, shared by the signal test files.
|
||||
- Live masks in the session (`LiveLayerMask.cpp`): `adoptClipping` and `releaseDetachedClipping` are static members, as Swift's `static func`s. `removeLiveMask` and `releaseDetachedClipping` walk the layers once per parent in list order, where Swift builds sibling arrays; the runs they free are the same. `LiveMaskGraph::validate` already refuses a link to oneself, a folder at either end and a missing source, so `canLinkMask` asks only for editable layers, an existing target and a valid graph; Swift's own checks of those cases, both folder checks of `toggleClippingMask` and `canToggleClippingMask` (the layer's own and its lower sibling's: the store validates the graph on load and `canLinkMask` on every link, so no folder is ever clipped and none becomes a base), and the folder case of the run's base in `releaseDetachedClipping` (nothing is ever clipped to a folder) are dead and left out; both toggle functions take the base from one helper, `clippingBase`. `adoptClipping` keeps its folder check: it writes the link without asking the graph, and `LiveMaskEditTests` calls it directly, because `placeLayer`'s release hides the side of the rule that looks below. `deleteWithLiveMaskChoice` shows a `QMessageBox` from the session, because Swift's session shows an `NSAlert` there: three buttons with Swift's texts (accept, reject and destructive roles), and `LiveMaskDeleteTests` drives the real alert from a zero timer under `QTEST_MAIN`. The bake runs through `QtConcurrent::run` and a `QFutureWatcher` with the project busy; layers are deleted first and the project freed last, as Swift's `defer` does; a failure sets `brushError` (a getter and a setter, arriving here because this is its first writer) and logs a warning. `LiveMaskBaker::bake` draws the target in its own pixel grid and each source through the inverse of the target's pixel-to-document map; the target's own mask, opacity and blend mode stay on the layer. A snapshot that repeats a layer id is a `logic_error` once the target and its pixels are found, where Swift's `Dictionary(uniqueKeysWithValues:)` traps. The bake's worker catches `ExportError` alone, as the import's worker catches `ImageImportError`: anything else is a bug and is left to crash. `releaseDetachedClipping` keys its runs by the optional parent itself, because all zeros is a valid folder id. `PixelAdjust::thumbnail` came forward from 9.2 for the baker. Over the renderer's pixel budget a bake draws nothing, on macOS too.
|
||||
- `ProjectController` is a `QObject`. Its `async` calls take a completion callback, posted to the event loop like the session's; steps inside continue inline. `newCanvas` and `close` take none: no caller awaits them. The store runs through `QtConcurrent::run` with one `QFutureWatcher` a call, a child of the controller, so an answer dies with it; the worker catches `std::runtime_error` (both of the store's error types) and anything else crashes. Panels are `QFileDialog`s and alerts `QMessageBox`es shown with `open()` over `window` (a `QPointer<QWidget>`), so nothing blocks; without a window they stand alone, where Swift runs the alert modally. A project is a folder: the open panel is in `Directory` mode and takes one project (no `QFileDialog` mode takes several folders, as Swift's workspace panel takes several packages); the save panel is a file panel with the `comp` suffix, and a typed name that is an existing project enters that folder, as any save dialog does (probed; GTK's does too), so Save As cannot land on an existing project. An accepted panel always holds a choice, read as `selectedFiles().value(0)`. A chosen name that does not end in `comp` (in any case) gets `.comp` appended, where `NSSavePanel` enforces the project type: `setDefaultSuffix` only fills in a missing suffix. The panel never saw that completed name, so if anything exists there (a link that leads nowhere counts) the save is refused with an alert and nothing is replaced. `open` trims the slashes at the end of its path, supplied or chosen in the panel: a shell completes a folder's name with a slash, and `QFileInfo` finds no name behind one, where Swift's `lastPathComponent` does. It trims only the end: `QDir::cleanPath` would also collapse `link/..`, which names another folder once `link` is a symbolic link. Callbacks that the session keeps (`waitForFileRequest`, `importImages`) hold the controller through a `QPointer`, because the session may outlive it; everything else hangs on the controller itself (connections, watchers, posted calls) and ends with it. The alert's roles carry the keys: Return saves, Escape cancels. Swift's `session.projectURL = …` is `setProjectPath`, which notifies; `markSaved()` needs no signal of its own, because every path from a save goes on to free the project, and that notifies. `samePlace` compares `canonicalFilePath()`s where Swift compares `resolvingSymlinksInPath()`. There are no recent documents (Qt has no API for the desktop's list) and no security scopes. `receive` keeps Swift's queue: requests drain one at a time from the event loop, each after `waitForFileRequest`; a project is known by its suffix in any case, a trailing slash aside. `tests/DialogDesk.h` answers the real dialogs from a timer: it types into a panel's name field (`selectFile` does nothing while that field has focus) and clicks or keys an alert. While a desk lives the working directory is a temporary folder of its own, so a panel never starts in the source tree (a mutant once saved `Untitled.comp` there and skewed the runs after it).
|
||||
- `ProjectController` is a `QObject`. Its `async` calls take a completion callback, posted to the event loop like the session's; steps inside continue inline. `newCanvas` and `close` take none: no caller awaits them. The store runs through `QtConcurrent::run` with one `QFutureWatcher` a call, a child of the controller, so an answer dies with it; the worker catches `std::runtime_error` (both of the store's error types) and anything else crashes. Panels are `QFileDialog`s and alerts `QMessageBox`es shown with `open()` over `window` (a `QPointer<QWidget>`), so nothing blocks; without a window they stand alone, where Swift runs the alert modally. A project is a folder: the open panel is in `Directory` mode and takes one project (no `QFileDialog` mode takes several folders, as Swift's workspace panel takes several packages); the save panel is a file panel with the `comp` suffix, and a typed name that is an existing project enters that folder, as any save dialog does (probed; GTK's does too), so Save As cannot land on an existing project. An accepted panel always holds a choice, read as `selectedFiles().value(0)`. A chosen name that does not end in `comp` (in any case) gets `.comp` appended, where `NSSavePanel` enforces the project type: `setDefaultSuffix` only fills in a missing suffix. The panel never saw that completed name, so if anything exists there (a link that leads nowhere counts) the save is refused with an alert and nothing is replaced. `open` trims the slashes at the end of its path, supplied or chosen in the panel: a shell completes a folder's name with a slash, and `QFileInfo` finds no name behind one, where Swift's `lastPathComponent` does. It trims only the end: `QDir::cleanPath` would also collapse `link/..`, which names another folder once `link` is a symbolic link. Callbacks that the session keeps (`waitForFileRequest`, `importImages`) hold the controller through a `QPointer`, because the session may outlive it; everything else hangs on the controller itself (connections, watchers, posted calls) and ends with it. The alert's roles carry the keys: Return saves, Escape cancels. Swift's `session.projectURL = …` is `setProjectPath`, which notifies; a save marks the saved revision before `setProjectPath`, whose signal then shows the saved state (12.13b: the write lands after the project is free again). `samePlace` compares `canonicalFilePath()`s where Swift compares `resolvingSymlinksInPath()`. There are no recent documents (Qt has no API for the desktop's list) and no security scopes. `receive` keeps Swift's queue: requests drain one at a time from the event loop, each after `waitForFileRequest`; a project is known by its suffix in any case, a trailing slash aside. `tests/DialogDesk.h` answers the real dialogs from a timer: it types into a panel's name field (`selectFile` does nothing while that field has focus) and clicks or keys an alert. While a desk lives the working directory is a temporary folder of its own, so a panel never starts in the source tree (a mutant once saved `Untitled.comp` there and skewed the runs after it).
|
||||
- `ProjectWorkspace` is a `QObject` with one signal, `changed()`, as the session has: it mutates itself, so the rule for `@Observable` classes that only the session writes does not cover it. `tabs`, `selectedID` and `isManaging` are read through getters. A `ProjectTab` holds its session and controller by value, the session first, and lives in a `std::shared_ptr`, as Swift's tabs are references: a flow that awaits a tab's controller or session (a load, a close, a quit, an import, a copy) keeps that tab alive in its callback until the answer has run, as Swift's `await` does, so a tab removed meanwhile still ends its flow. Every callback the workspace hands to a tab's controller or session holds the workspace through a `QPointer`. Swift's `async` calls take completion callbacks posted to the event loop; `close` takes one too, because its tests await it. The open panel takes one project (see the controller) and is titled `Open Project` like the controller's, where Swift's bare `NSOpenPanel` says `Open`: Qt's default for a folder panel is `Find Directory`. Managing ends last in every flow, after the sessions are free again, so that the workspace's last signal sees `canSwitch` as the call leaves it. A title cuts the suffix as Swift's `deletingPathExtension` does (`nameWithoutSuffix`: a name's first or last dot cuts nothing, so `.comp` stays whole), where `completeBaseName` would leave nothing. `samePlace` falls back on the absolute path (`absoluteFilePath`, which also cleans it) when a place no longer resolves, as `resolvingSymlinksInPath` does, because two missing places both canonicalise to the empty string. A loaded tab's default name is never shown (its title comes from the session's path, which the controller trims), so `loadProject` names it from the path as given. `receive` looks again every 30 ms while the workspace cannot switch, as Swift sleeps; `Task.isCancelled` has no twin. `receiveProviders` takes the drag's `QMimeData`: the layer-row type carries a UUID, judged by its text so that all zeros stays a valid id (a payload under another type is no row and goes to `ImageFileDrop` since 10.4, which reads nothing there); files and pictures arrive with 10.4. `copyLayers` (since 12.10b; `copyLayer` hands it one id, and a drop still copies row by row, as Swift's) bakes every mask that comes from outside the copy in one worker task (Swift awaits them one by one), then places the layers from a friend's seat in the target session, because Swift writes that document in place; `ProjectController::isProject` and `samePlace` are public statics so that both classes read paths alike. The anchor is the first layer's centre, or with several the centre of the box round every copied layer that is no folder; the `?? centre` fallback is dead (the first id is always among the copies) and is a checked read instead. The copies end up selected, the first active; an id the source tab lacks (two tabs of one project share ids, and the first holding the first id is the source) is left out of the selection, as Swift's `compactMap`.
|
||||
- `CanvasView` twins `EditorCanvas.swift`, split as `EditorCanvas.cpp` (the display) and `EditorCanvas+Navigation.cpp` (events, cursors, focus; keys in `EditorCanvas+Keys.cpp` since 12.2b). It follows Swift's `updateNSView`: `ContentView` calls `consumeFocusRequest` and `synchronizeDisplay` on every `changed()`, and a canvas alone follows nothing, as Swift's alone observes nothing. `paintEvent` clips to the dirty rect (the region's union, as `draw(_:)` receives it) and paints it through `AdjustmentSurface::draw`, so hand-blended modes and clip coverage meet an image-backed device and device-sized coverage costs only the dirty area; the smooth path draws its layers through a second surface, Swift's transparency layer, so blend modes never meet the checkerboard. `DisplayState` holds what Swift's holds (`brushRevision` came with 8.2, adjustments with 9.5, effects with 9.6c, the crop union in `renderBounds` with 10.1); with a live mask every layer counts, hidden ones included, since a hidden source still clips. The surround outside the document is the palette's `Base`, which the built-in dark sets to Swift's 0.105 gray and a light Omarchy theme to its light `darker_background`, so the welcome reads over it. The shadow is fourteen rings of black stacking to 35% at the edge (Qt has no blur); the border is antialiased, half in and half out, as CoreGraphics strokes it. A wheel without pixel deltas is read in lines, `QApplication::wheelScrollLines()` a notch, twelve points a line as Swift's non-precise scroll; `Qt::ZoomNativeGesture` is `magnify`. Geometry reaches the viewport from the event loop (`syncGeometry` is posted, as Swift defers past SwiftUI's layout) and notifies the session; `QWindow::screenChanged` stands in for `viewDidChangeBackingProperties`. A shown canvas takes the keys when a document exists and no sheet, importer or modal dialog holds them; `EditorSession::requestCanvasFocus` counts Swift's `canvasFocusRequest`, `NavigationToolHeader` asks on Return and Escape, and `ContentView` posts one when a document appears (Swift's `onChange`). Cursors: the open and closed hands for Hand and Space, a drawn magnifier for Zoom (Alt shows the minus), the I-beam for Type, the arrow for Idle and Move (the transform cursors arrive with 6.1), the cross for the rest; auto-repeat releases of Space and Alt are no release. Keys: the tool letters, Space, Escape and Return around a transform edit, arrows nudging in Move; the rest arrive with their tools. Swift's local monitors (`viewDidMoveToWindow`) are an application event filter installed while the canvas is shown: Shift-+ and Shift-− step the blend mode from any control of the window but a text field (`QLineEdit`) or the canvas holding open text (Swift's monitor stands aside for any `NSText` first responder, the inline text view included; since 8.9b), and Alt reaches the Zoom cursor from any window, an open menu's included (Swift's modifier monitor is application-wide); a blend key from another window, a dialog included, is that window's own.
|
||||
- `BrushRaster::visibleRect` and `AdjustmentSurface::draw` work on widget painters as on images: a widget's `QPainter::device()` measures itself in logical pixels where an image's measures device pixels, `combinedTransform()` carries the device pixel ratio, and `deviceTransform()` also the widget's offset in its window's backing store, whose image is the engine's `paintDevice()`. `HighDpiCanvasTests` holds this at ratio 2 through a `main` of its own that sets `QT_SCALE_FACTOR`.
|
||||
@@ -142,6 +142,7 @@ Platform adaptations, each forced by a missing Qt analog:
|
||||
- Scrubbable labels (12.12c1, Swift 1.2.11: `2d36d6e`, `680a522`): `UI/NumericScrub` twins Swift's modifier as a `QObject` living as its label's child and filtering its events; `scrubbable` and `unitSuffix(_:scrubValue:)` are its constructor on the label or unit (`Options`: sensitivity, range, optional step, value, set, onStart, onEnd). A left press on an enabled label, a double click's second press included, is taken (a disabled label's presses reach its filter before Qt drops them, so the filter asks), so the label keeps the drag past its edge; the first move off the press begins (Swift's `minimumDistance` of one, reached by any move of whole points), keeps the start value and calls `onStart`; each move sets the start plus the sideways distance (in the label's own points, which stay exact, where Qt's `notify` rounds global ones) times the sensitivity, snapped to the step when positive, then clamped; the left release calls `onEnd`. Swift's Int overload is a step of one. The cursor is the label's own `SizeHorCursor`: Qt keeps a grabbing widget's cursor through the drag and shows a disabled label's parent's, so Swift's hover and restore dance has no twin. Qt can lose a release: the left release is watched application-wide while pressed, and a move without the left button ends the drag (the label tracks the mouse, so such a move arrives). The tool bars scrub as Swift's: the Brush's Size, Hardness, Opacity or Strength and Smoothing, the Gradient's Opacity, the Magic Wand's Tolerance, Object Select's Edge, the Expand, Contract and Feather units (Expand's and Contract's rest with their rows; Feather's amount and unit stay enabled without a selection, its button alone resting, as Swift's, which the twin's amount had missed), the amount sheet's title (writing its input), the Shape's Width and Radius (a scrub writes its field's text over any typing, as a step does: `showValue` on `SelectionAmountField` and the Type bar's `TextStyleField`), the Type bar's size unit, Tracking and Leading (whole numbers), the Move bar's labels (whole numbers in Swift's new ranges: ±30,000, sides 1 to 30,000, Scale 0.1 to 30,000%, rotation ±360; the scrub writes its field's text over any typing, then the value, so an unfocused field shows what the session keeps; another active layer ends each field's drag under way, `TransformValueField::endScrub`, as it ends the opacity's), the zoom's unit (through the field's `step`) and the layer opacity (one opacity drag, `beginOpacityEdit` to `finishOpacityEdit`; another active layer ends a drag under way through `NumericScrub::end`, since the twin keeps the controls where Swift's `.id` remakes them, and later held moves wait for a fresh press). The sheets and panels follow in 12.12c2. `075a71b`'s narrowing fix in `AdjustPixels.c` is taken (the kernels match Swift's); its other changes and `725b934`'s are Swift's concurrency, plist and CI, with nothing to twin. Swift's `emptyCanvasLeavesTheScrubberCursorAloneAndRestoresTrackingForADocument` has no twin: the canvas never sets a global cursor, only its own. Equivalent mutant: removing the application filter when a drag ends (it then sees nothing to end). Tests: `NumericScrubTests`, `ScrubbableLabelsTests`, `ScrubbableTransformTests` (the Move bar, the zoom unit, the layer opacity), `ScrubFixtures.h`.
|
||||
- Scrubbable titles in the sheets and panels (12.12c2, the rest of `2d36d6e` and `680a522`): each title (or channel letter) is a `NumericScrub` with Swift's sensitivity, range and step, and every scrub replaces its field's typing (`setModified(false)`, as the row's slider does) before the sheet shows the value. Effects' rows scrub to the typed limit (Swift's `inputRange`, the stroke to 500); Hue/Saturation's titles take their range from Colorize, the Canvas and Image Size titles theirs and their sensitivity from the unit, Relative, Lock, Resample and the resolution, so the owner hands new limits to `NumericScrub::reshape` on every synchronize, as SwiftUI's next render hands the modifier new ones. Levels' captions move a tone a level a point and gamma a hundredth, within 0 to 255 and 0.1 to 9.99; a filter's title moves the field's last decimal a point (`1 / 10^decimals`) over the linear range, logarithmic sliders included, as Swift's; the colour picker's R, G and B move whole levels. Canvas Size snaps to whole units in pixels, percent, inches or centimetres, its range Swift's `scrubRange` (a locked ratio keeps both sides within 1 and 30,000; Relative counts from the current size); Image Size's titles rest while a physical unit has no positive resolution (Swift's `canScrubDimensions`), resampling spans Swift's pixel and hundred-megapixel limits, a print size moves a hundredth of an inch (0.0254 cm) a point down to the pixels at 9,600 per inch, and Resolution scrubs whole pixels per inch from 1 to 9,600, through the same `setResolution` a typed one takes (a physical size keeps, its pixels scale). Camera Raw's rows opt in through `CameraRawRow::Spec::scrub` (the sensitivity), scrubbing through the row's typed setter, unrounded and unclipped, as Swift's bindings do: the main rows a point per last decimal, the curve's amounts and Refine Saturation, the HSL family titles (the Color and Point Color rows stay passive, as Swift's `colorSlider` and `pointSlider`), Detail, Optics, Geometry and Calibration a unit a point; Color Grading's Blending and Balance do not scrub, as in Swift. A title's double-click reset runs first and lets the press through, so the second press still scrubs, as Swift's simultaneous gesture does. Known: a sheet destroyed while one of its fields holds the focus has that field commit into the sheet's destroyed members as Qt moves the focus; the app always hides a panel's content before deleting it, so the focus leaves first, and `ScrubFixtures`' typing helper leaves the field before the sheet goes. Equivalent mutant: the colour channels' step of one (the picker's colour is quantized to 8 bits, so a channel lands on whole levels anyway). Tests: `ScrubbableSheetsTests`, `ScrubbableCameraRawTests` (each row group's value, setter, sensitivity and limits; the passive titles).
|
||||
- Projects changed on disk (12.13a, Swift 1.3: `ed5ddea`, `cd4061a`, `cba6efc`): `IO/ProjectWatcher` twins Swift's class with a `QFileSystemWatcher` (inotify) on the package folder, its manifest and its images folder, where Swift opens kernel vnode sources: an event restarts a 300 ms single-shot delivery (Swift's coalescing) and re-arms every path from scratch each 100 ms, up to 20 times, until all three are watched (both timers are named children, `delivery` and `rearm`, so tests read them, and precise, since a coarse timer may fire 5% early where Swift's sleeps never end early; so is the controller's recheck); Qt's inotify engine subscribes to no `IN_MOVE_SELF`, where Swift's sources watch `.rename`, so a package exchanged for another one kept aside would pass unseen: the watcher also watches the package's parent folder and counts an event there only when the package path's device and inode differ from the ones it armed on, because a package swapped in (our store's `renameat2` exchange, another app's rename) retires the watched inodes. `IO/ProjectDigest` is a namespace: SHA-256 (`QCryptographicHash`) over the manifest's bytes, then each regular file in `images/` (links and folders skipped, names sorted by UTF-16 units) as its UTF-8 name and its size as a little-endian 64-bit number; a manifest that cannot be opened, or reads short of its size (a failed read, as Swift's `Data(contentsOf:)` throws), or an image gone mid-listing, throws, which the callers read as no digest (Swift's `try?`). `ProjectController+ExternalChanges.cpp` holds Swift's extension, its state `ExternalChangeState` (`ProjectController+ExternalChanges.h`) a public member, as Swift's: the watcher, the known digest, `checking`, `pending`, `saving`, the recheck `QTimer` (single-shot, a child of the controller), `recheckAttempt` and `reloadCount`. Swift's async loop runs as callbacks: each pass takes the pending flag, stops without a path, a document or while our own save writes, computes the digest off the UI thread (a `QFutureWatcher` child of the controller, so an answer dies with it), passes over a digest that matches, backs off at 250 ms × 2ⁿ (n to 7) while the project cannot start an operation, a transform is open or the workspace manages, reloads an unmodified document, and for unsaved work asks Swift's alert (Revert, Keep Mine) over the window, only for the front tab; without a window, or behind another tab, the change waits for `resumeExternalChangeCheck`, which the workspace's `select` calls. Keep Mine remembers the new digest. A reload (`reloadFromDisk`) marks the project busy, loads through the store off the UI thread, lands only on the same path with a document still open, calls `EditorSession::reloadProject`, remembers the digest as loaded and then counts the reload; a package that fails to load leaves the document alone and logs on `omaphoto.io`. `reloadProject` installs the snapshot and puts back the viewport, the collapsed folders that still exist, and the active layer with the selection's surviving members when the active layer still exists, as Swift's; history starts over. An open (the controller's own, which the workspace's tabs use) and a save remember the digest, then watch the package; a save marks `saving` from before its worker starts until the digest of what it wrote is remembered, or, when it fails, until its alert is answered (Swift's `defer`), so its own events pass; a new canvas and a close stop the watch. Swift's decoding from bytes in memory (`cba6efc`) has no twin: `QImageReader` decodes a PNG into memory the image owns, never mapped from the file, so a later save that replaces the file cannot reach an image kept for undo. Equivalent mutants: the try count's reset on a new event (a missing folder's creation is itself an event on the package, which re-arms), and the reload's two log lines. Tests: `ExternalChangeTests` (Swift's six; the revert question both ways, Keep Mine remembering, a background tab asking when brought forward, a busy project backing off then reloading, new canvas and close stopping the watch and its recheck, one check at a time, a check ending without a project or during our save), `ExternalChangeGateTests` (a failed save's guard, a managing workspace, the back-off's intervals, a load landing elsewhere, the reload's whole view and last signal, an unreadable manifest, Save As and open moving the watch, a second change while the question waits), `ExternalChangeFixtures.h`, `ProjectDigestRaceTests` (a binary of its own: `readdir` and `readdir64` take libc's place and remove an image as its folder's listing ends, so the digest's throw is ordered, not raced), `ProjectWatcherTests` (one report a burst, the quiet time from the last event, twenty re-arm tries, each watch speaking alone, an exchange keeping the old package, precise timing, re-arming and giving up, the digest's exact input and order, an unreadable manifest).
|
||||
- Editing while a project saves (12.13b, Swift 1.3: `940e188`): a save holds the tools only while it captures the document (`prepareSave`, Swift's: the revision, the snapshot, and the Save panel when there is no path yet or for Save As), then reserves the writer and only then frees the project, so no save asked in that signal slips in beside it, and writes in the background; a capture finds no document when Undo took it while the save waited (Swift's `guard let snapshot`), and the save answers false; `DocumentHistory::currentRevision` and `markSaved(QUuid)` twin Swift's, so only the captured version counts as saved: edits made while it writes stay modified, and undoing back to it is unmodified. Swift's `writing` task is `m_writing` with a list of waiters: `finishWriting` runs its callback at once when nothing writes, else when the write lands, each waiter posted to the event loop and passed through `finishWriting` again, so one writer runs at a time where Swift's store actor serialises the disk; `save` waits there before `begin`, so a second save writes what changed since, and `confirmReplacement` waits there first, so a close, a quit, a new canvas or an open never cuts a file short and asks only once the write is whole; an open also waits before its first read, and so does an external reload (12.13a's `reloadFromDisk`), since the twin's store is no actor and could read a package mid-save: a Revert answered while our save writes loads what we wrote. A digest that comes back while our save writes is looked at again once the write lands (pending, through `finishWriting`): after a save it matches what we wrote, after a failed one the change is taken up; Keep Mine remembers its digest only when no save landed while the question was up. A save queued behind a quit that already holds the tools is refused, as Swift's `begin` refuses it. A save that fails frees its waiters after its alert is answered. The refusal of a completed Save As name that exists sits in `prepareSave`, beside the panel. Swift has no tests for this commit: `ProjectSaveWhileEditingTests` pins it (edits while writing, a second save, a new canvas waiting, the panel alone holding the tools, a failed write, the last signal), and, over `tests/SaveHooks.h` (`fsync` slowed and counting overlapping writes, `renameat2` held at a package's swap, `readdir` and `readdir64` held at one folder, the store's loads counted from its log, every hold let go on cleanup), `ProjectSaveOrderTests` (a reopen reading nothing while the write is held, a Revert mid-save, the guard through the save's digest, Canvas Size and Image Size each committed before and after the held write lands, a digest out as our save begins, a failed save keeping an external change in line, a second save waiting through the first save's digest, Keep Mine after a save) and `ProjectSaveQueueTests` ( three saves one at a time, a save asked as the tools free, a quit waiting, a document undone away, a failed write holding its waiters, a reopen, a tab closing, a controller gone mid-write, an export beside a write).
|
||||
- Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to <title>"), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4.
|
||||
- `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is `LayerMask::background`, white or black by its edge's majority, as the canvas treats it (Swift's `edgeTone` mean went with 12.12b). `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does.
|
||||
- `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip`, a spacer and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a `QScrollArea` whose buttons are kept for the tabs that stay, moved for the tabs that moved, made for new tabs and deleted for tabs that went; its row is sized to its tabs by hand (a resizable scroll area would squeeze every title below Swift's 35 points) and resized again whenever a title changes, and the front tab scrolls into view at once; it and its viewport fill no background, so the toolbar shows through as Swift's mask lets it (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4.
|
||||
|
||||
@@ -387,7 +387,7 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i
|
||||
- [x] 12.12c1 Part three: numeric label scrubbing (`2d36d6e`, `680a522`) in the tool bars and the layer opacity; the build-warning fixes (`075a71b`, `725b934`) where they change behaviour. Codex review: 3 rounds. Round 1: focused fields left stale, double-click second presses, Feather's amount wrongly resting, test gaps. Round 2: an opacity scrub outliving its layer, boundary gaps. Round 3: the same for the Move bar. All fixed; closed after the third.
|
||||
- [x] 12.12c2 Numeric label scrubbing in the sheets and panels: Canvas Size, Image Size, the colour picker, effects, filters, Hue/Saturation, Levels, Camera Raw. Codex review: 3 rounds. Round 1: every Mixer row scrubbed where Swift scrubs the HSL families alone, and test gaps (Image Size limits, Camera Raw bindings, filter decimals, centimetres). Round 2: test gaps (a held drag across `reshape`, titles enabled again, Alt-held Camera Raw scrubs). Round 3 passed.
|
||||
- [x] 12.13a Version 1.3: reloading a project changed on disk (`ed5ddea`, merged in 1.3), its manifest-and-sizes fingerprint (`cd4061a`), images decoded from memory (`cba6efc`). Codex review: 3 rounds. Round 1: a package exchanged and kept aside unseen (Qt watches no IN_MOVE_SELF), a failed manifest read still digested, coarse timers, a failed save's guard released early, and test gaps. Round 2: test gaps (the watch moving, a second change while asking, quiet-time and re-arm counts, an ordered test for a vanished image). Round 3 passed.
|
||||
- [ ] 12.13b Keep editing while a project saves (`940e188`).
|
||||
- [x] 12.13b Keep editing while a project saves (`940e188`). Codex review: 3 rounds. Round 1: overlapping writes and a quit before a write finished, a queued save crashing after Undo took the document, a reopen reading a package mid-save, and test gaps. Round 2: an external reload reading mid-save, and test gaps. Round 3: a failed save stranding an external change already seen, and test gaps. All fixed; closed after the third.
|
||||
- [ ] 12.13c The guide for AI agents writing projects (`c317306`), README's lines.
|
||||
- [ ] 12.14 Versions 1.3.1–1.3.2: quitting and closing while text is edited, the I-beam over the canvas, File > Open Recent, Image Size keeping print sizes, Camera Raw's Color Grading layout, coloured slider tracks with double-click reset, colouring only the selected letters, sampling keeping a panel's focus, filters waiting for text editing to finish.
|
||||
- [ ] 12.15 Version 1.3.3: the Dither filter.
|
||||
|
||||
@@ -26,6 +26,10 @@ public:
|
||||
bool isModified() const { return m_revision != m_savedRevision; }
|
||||
int undoCount() const { return int(m_past.size()); }
|
||||
void markSaved() { m_savedRevision = m_revision; }
|
||||
// The document now, for a save that finishes later.
|
||||
QUuid currentRevision() const { return m_revision; }
|
||||
// A save of `saved` finished; later edits stay modified.
|
||||
void markSaved(QUuid saved) { m_savedRevision = saved; }
|
||||
void reset();
|
||||
// Takes the steps a staged copy of this history recorded.
|
||||
void adopt(DocumentHistory &&staged) noexcept;
|
||||
|
||||
@@ -83,6 +83,13 @@ void ProjectController::checkExternalChange()
|
||||
connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, path = *path] {
|
||||
watcher->deleteLater();
|
||||
const std::optional<QByteArray> digest = watcher->result();
|
||||
// Our save began meanwhile: look again once it lands.
|
||||
if (externalChanges.saving) {
|
||||
externalChanges.pending = true;
|
||||
externalChanges.checking = false;
|
||||
finishWriting([this] { resumeExternalChangeCheck(); });
|
||||
return;
|
||||
}
|
||||
if (!digest || digest == externalChanges.knownDigest) {
|
||||
checkExternalChange();
|
||||
return;
|
||||
@@ -102,9 +109,12 @@ void ProjectController::checkExternalChange()
|
||||
externalChanges.checking = false;
|
||||
return;
|
||||
}
|
||||
askToRevert([this, path, digest](bool revert) {
|
||||
const int askedAtSave = m_saveGeneration;
|
||||
askToRevert([this, path, digest, askedAtSave](bool revert) {
|
||||
if (!revert) {
|
||||
externalChanges.knownDigest = digest;
|
||||
// A save while asking remembered what it wrote.
|
||||
if (m_saveGeneration == askedAtSave)
|
||||
externalChanges.knownDigest = digest;
|
||||
checkExternalChange();
|
||||
return;
|
||||
}
|
||||
@@ -132,22 +142,25 @@ void ProjectController::reloadFromDisk(const QString &path, std::function<void()
|
||||
{
|
||||
externalChanges.recheckAttempt = 0;
|
||||
session.setIsProjectBusy(true);
|
||||
// A package that fails to load leaves the document alone.
|
||||
load(path, [this, path, then](const Loaded &loaded) {
|
||||
if (!loaded.snapshot || session.projectPath() != path || !session.document()) {
|
||||
if (!loaded.snapshot)
|
||||
qCInfo(lcIO).noquote() << "the changed package did not load yet:" << loaded.failure;
|
||||
session.setIsProjectBusy(false);
|
||||
then();
|
||||
return;
|
||||
}
|
||||
session.reloadProject(*loaded.snapshot);
|
||||
// As loaded, not first seen: it may have changed.
|
||||
rememberProjectDigest(path, [this, path, then] {
|
||||
externalChanges.reloadCount += 1;
|
||||
qCInfo(lcIO).noquote() << "reloaded" << path << "after it changed on disk";
|
||||
session.setIsProjectBusy(false);
|
||||
then();
|
||||
// Swift's store is an actor: no read mid-write.
|
||||
finishWriting([this, path, then] {
|
||||
// A package that fails to load leaves the document alone.
|
||||
load(path, [this, path, then](const Loaded &loaded) {
|
||||
if (!loaded.snapshot || session.projectPath() != path || !session.document()) {
|
||||
if (!loaded.snapshot)
|
||||
qCInfo(lcIO).noquote() << "the changed package did not load yet:" << loaded.failure;
|
||||
session.setIsProjectBusy(false);
|
||||
then();
|
||||
return;
|
||||
}
|
||||
session.reloadProject(*loaded.snapshot);
|
||||
// As loaded, not first seen: it may have changed.
|
||||
rememberProjectDigest(path, [this, path, then] {
|
||||
externalChanges.reloadCount += 1;
|
||||
qCInfo(lcIO).noquote() << "reloaded" << path << "after it changed on disk";
|
||||
session.setIsProjectBusy(false);
|
||||
then();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+121
-64
@@ -68,25 +68,54 @@ void ProjectController::finish(const std::function<void(bool)> &done, bool value
|
||||
|
||||
void ProjectController::save(bool asNew, std::function<void(bool)> done)
|
||||
{
|
||||
if (!session.document() || !begin()) {
|
||||
if (!session.document()) {
|
||||
finish(done, false);
|
||||
return;
|
||||
}
|
||||
saveCurrent(asNew, [this, done](bool saved) {
|
||||
session.setIsProjectBusy(false);
|
||||
finish(done, saved);
|
||||
// A save still writing finishes; this one saves what changed.
|
||||
finishWriting([this, asNew, done] {
|
||||
if (!begin()) {
|
||||
finish(done, false);
|
||||
return;
|
||||
}
|
||||
prepareSave(asNew, [this, done](std::optional<Prepared> prepared) {
|
||||
// The writer is reserved before the tools go free.
|
||||
if (prepared)
|
||||
write(*prepared, [this, done](bool saved) { finish(done, saved); });
|
||||
session.setIsProjectBusy(false);
|
||||
if (!prepared)
|
||||
finish(done, false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void ProjectController::saveCurrent(bool asNew, std::function<void(bool)> then)
|
||||
{
|
||||
const std::optional<ProjectSnapshot> snapshot = session.projectSnapshot();
|
||||
if (!snapshot) {
|
||||
if (!session.document()) {
|
||||
then(true);
|
||||
return;
|
||||
}
|
||||
prepareSave(asNew, [this, then](std::optional<Prepared> prepared) {
|
||||
if (!prepared) {
|
||||
then(false);
|
||||
return;
|
||||
}
|
||||
write(*prepared, then);
|
||||
});
|
||||
}
|
||||
|
||||
void ProjectController::prepareSave(bool asNew, std::function<void(std::optional<Prepared>)> then)
|
||||
{
|
||||
const QUuid revision = session.history.currentRevision();
|
||||
// Undo may have taken the document while this waited.
|
||||
const std::optional<ProjectSnapshot> captured = session.projectSnapshot();
|
||||
if (!captured) {
|
||||
then(std::nullopt);
|
||||
return;
|
||||
}
|
||||
const ProjectSnapshot snapshot = *captured;
|
||||
if (!asNew && session.projectPath()) {
|
||||
write(*snapshot, *session.projectPath(), then);
|
||||
then(Prepared{snapshot, *session.projectPath(), revision});
|
||||
return;
|
||||
}
|
||||
auto *panel = new QFileDialog(window, asNew ? QStringLiteral("Save Project As") : QStringLiteral("Save Project"));
|
||||
@@ -94,15 +123,15 @@ void ProjectController::saveCurrent(bool asNew, std::function<void(bool)> then)
|
||||
panel->setAcceptMode(QFileDialog::AcceptSave);
|
||||
panel->setDefaultSuffix(QStringLiteral("comp"));
|
||||
panel->selectFile(session.projectPath() ? QFileInfo(*session.projectPath()).fileName() : QStringLiteral("Untitled.comp"));
|
||||
connect(panel, &QDialog::finished, this, [this, panel, snapshot = *snapshot, then](int result) {
|
||||
connect(panel, &QDialog::finished, this, [this, panel, snapshot, revision, then](int result) {
|
||||
if (result != QDialog::Accepted) {
|
||||
then(false);
|
||||
then(std::nullopt);
|
||||
return;
|
||||
}
|
||||
// The panel only suggests the suffix; a project needs it.
|
||||
const QString chosen = panel->selectedFiles().value(0);
|
||||
if (isProject(QUrl::fromLocalFile(chosen))) {
|
||||
write(snapshot, chosen, then);
|
||||
then(Prepared{snapshot, chosen, revision});
|
||||
return;
|
||||
}
|
||||
// The panel never saw this name: nothing there is replaced.
|
||||
@@ -110,36 +139,58 @@ void ProjectController::saveCurrent(bool asNew, std::function<void(bool)> then)
|
||||
// A link that leads nowhere is still in the way.
|
||||
if (renamed.exists() || renamed.isSymLink()) {
|
||||
showError(QStringLiteral("Couldn’t save the project"),
|
||||
QStringLiteral("“%1” already exists. Choose another name.").arg(renamed.fileName()), [then] { then(false); });
|
||||
QStringLiteral("“%1” already exists. Choose another name.").arg(renamed.fileName()), [then] { then(std::nullopt); });
|
||||
return;
|
||||
}
|
||||
write(snapshot, renamed.filePath(), then);
|
||||
then(Prepared{snapshot, renamed.filePath(), revision});
|
||||
});
|
||||
panel->open();
|
||||
}
|
||||
|
||||
void ProjectController::write(const ProjectSnapshot &snapshot, const QString &destination, std::function<void(bool)> then)
|
||||
void ProjectController::finishWriting(std::function<void()> then)
|
||||
{
|
||||
if (!m_writing) {
|
||||
then();
|
||||
return;
|
||||
}
|
||||
m_writeWaiters.push_back(std::move(then));
|
||||
}
|
||||
|
||||
// Writes a captured document; only that version counts as saved.
|
||||
void ProjectController::write(const Prepared &prepared, std::function<void(bool)> then)
|
||||
{
|
||||
m_writing = true;
|
||||
// Waiters go on from the event loop once it lands.
|
||||
const auto landed = [this, then](bool saved) {
|
||||
m_writing = false;
|
||||
// Each looks again: an earlier one may be writing.
|
||||
for (std::function<void()> &waiter : std::exchange(m_writeWaiters, {}))
|
||||
QMetaObject::invokeMethod(this, [this, waiter = std::move(waiter)] { finishWriting(waiter); }, Qt::QueuedConnection);
|
||||
then(saved);
|
||||
};
|
||||
// Our own save changes the package: its events are ours.
|
||||
externalChanges.saving = true;
|
||||
const QString destination = prepared.destination;
|
||||
const ProjectSnapshot snapshot = prepared.snapshot;
|
||||
auto *watcher = new QFutureWatcher<std::optional<QString>>(this);
|
||||
connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, destination, then] {
|
||||
connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, destination, revision = prepared.revision, landed] {
|
||||
watcher->deleteLater();
|
||||
if (const std::optional<QString> failure = watcher->result()) {
|
||||
// Swift's defer: the guard lasts until the alert is answered.
|
||||
showError(QStringLiteral("Couldn’t save the project"), *failure, [this, then] {
|
||||
showError(QStringLiteral("Couldn’t save the project"), *failure, [this, landed] {
|
||||
externalChanges.saving = false;
|
||||
then(false);
|
||||
landed(false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Marked first: the path's signal shows the saved state.
|
||||
session.history.markSaved(revision);
|
||||
session.setProjectPath(destination);
|
||||
session.history.markSaved();
|
||||
m_saveGeneration += 1;
|
||||
rememberProjectDigest(destination, [this, destination, then] {
|
||||
rememberProjectDigest(destination, [this, destination, landed] {
|
||||
watchProject(destination);
|
||||
externalChanges.saving = false;
|
||||
then(true);
|
||||
landed(true);
|
||||
});
|
||||
});
|
||||
// The store blocks on the disk: off the UI thread.
|
||||
@@ -190,36 +241,39 @@ void ProjectController::open(std::optional<QString> path, std::function<void(boo
|
||||
// A folder's trailing slash would leave it without a name.
|
||||
while (source.endsWith(QLatin1Char('/')))
|
||||
source.chop(1);
|
||||
// Validated first: a corrupt project never discards the live document.
|
||||
load(source, [this, end, refuse, source](const Loaded &first) {
|
||||
if (!first.snapshot) {
|
||||
refuse(first.failure);
|
||||
return;
|
||||
}
|
||||
const int previousSave = m_saveGeneration;
|
||||
confirmReplacement([this, end, refuse, source, first, previousSave](bool proceed) {
|
||||
if (!proceed) {
|
||||
end(false);
|
||||
// A write in flight lands first: no read mid-save.
|
||||
finishWriting([this, end, refuse, source] {
|
||||
// Validated first: a corrupt project never discards the live document.
|
||||
load(source, [this, end, refuse, source](const Loaded &first) {
|
||||
if (!first.snapshot) {
|
||||
refuse(first.failure);
|
||||
return;
|
||||
}
|
||||
// Saving in the confirmation can replace the opened file.
|
||||
if (m_saveGeneration == previousSave || !samePlace(session.projectPath(), source)) {
|
||||
session.installProject(*first.snapshot, source);
|
||||
rememberProjectDigest(source, [this, source, end] {
|
||||
watchProject(source);
|
||||
end(true);
|
||||
});
|
||||
return;
|
||||
}
|
||||
load(source, [this, end, refuse, source](const Loaded &again) {
|
||||
if (!again.snapshot) {
|
||||
refuse(again.failure);
|
||||
const int previousSave = m_saveGeneration;
|
||||
confirmReplacement([this, end, refuse, source, first, previousSave](bool proceed) {
|
||||
if (!proceed) {
|
||||
end(false);
|
||||
return;
|
||||
}
|
||||
session.installProject(*again.snapshot, source);
|
||||
rememberProjectDigest(source, [this, source, end] {
|
||||
watchProject(source);
|
||||
end(true);
|
||||
// Saving in the confirmation can replace the opened file.
|
||||
if (m_saveGeneration == previousSave || !samePlace(session.projectPath(), source)) {
|
||||
session.installProject(*first.snapshot, source);
|
||||
rememberProjectDigest(source, [this, source, end] {
|
||||
watchProject(source);
|
||||
end(true);
|
||||
});
|
||||
return;
|
||||
}
|
||||
load(source, [this, end, refuse, source](const Loaded &again) {
|
||||
if (!again.snapshot) {
|
||||
refuse(again.failure);
|
||||
return;
|
||||
}
|
||||
session.installProject(*again.snapshot, source);
|
||||
rememberProjectDigest(source, [this, source, end] {
|
||||
watchProject(source);
|
||||
end(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -293,26 +347,29 @@ void ProjectController::confirmQuit(std::function<void(bool)> done)
|
||||
|
||||
void ProjectController::confirmReplacement(std::function<void(bool)> then)
|
||||
{
|
||||
if (!session.isModified() || !session.document()) {
|
||||
then(true);
|
||||
return;
|
||||
}
|
||||
const QString name = session.projectPath() ? QFileInfo(*session.projectPath()).fileName() : QStringLiteral("Untitled");
|
||||
auto *alert = new QMessageBox(window);
|
||||
alert->setAttribute(Qt::WA_DeleteOnClose);
|
||||
alert->setIcon(QMessageBox::Warning);
|
||||
alert->setText(QStringLiteral("Save changes to %1?").arg(name));
|
||||
alert->setInformativeText(QStringLiteral("Your changes will be lost if you don’t save them."));
|
||||
const QPushButton *save = alert->addButton(QStringLiteral("Save"), QMessageBox::AcceptRole);
|
||||
alert->addButton(QStringLiteral("Cancel"), QMessageBox::RejectRole);
|
||||
const QPushButton *discard = alert->addButton(QStringLiteral("Don’t Save"), QMessageBox::DestructiveRole);
|
||||
connect(alert, &QDialog::finished, this, [this, alert, save, discard, then] {
|
||||
if (alert->clickedButton() == save)
|
||||
saveCurrent(false, then);
|
||||
else
|
||||
then(alert->clickedButton() == discard);
|
||||
// A save still writing finishes: no file is cut short.
|
||||
finishWriting([this, then] {
|
||||
if (!session.isModified() || !session.document()) {
|
||||
then(true);
|
||||
return;
|
||||
}
|
||||
const QString name = session.projectPath() ? QFileInfo(*session.projectPath()).fileName() : QStringLiteral("Untitled");
|
||||
auto *alert = new QMessageBox(window);
|
||||
alert->setAttribute(Qt::WA_DeleteOnClose);
|
||||
alert->setIcon(QMessageBox::Warning);
|
||||
alert->setText(QStringLiteral("Save changes to %1?").arg(name));
|
||||
alert->setInformativeText(QStringLiteral("Your changes will be lost if you don’t save them."));
|
||||
const QPushButton *save = alert->addButton(QStringLiteral("Save"), QMessageBox::AcceptRole);
|
||||
alert->addButton(QStringLiteral("Cancel"), QMessageBox::RejectRole);
|
||||
const QPushButton *discard = alert->addButton(QStringLiteral("Don’t Save"), QMessageBox::DestructiveRole);
|
||||
connect(alert, &QDialog::finished, this, [this, alert, save, discard, then] {
|
||||
if (alert->clickedButton() == save)
|
||||
saveCurrent(false, then);
|
||||
else
|
||||
then(alert->clickedButton() == discard);
|
||||
});
|
||||
alert->open();
|
||||
});
|
||||
alert->open();
|
||||
}
|
||||
|
||||
void ProjectController::showError(const QString &title, const QString &message, std::function<void()> then)
|
||||
|
||||
@@ -70,10 +70,20 @@ private:
|
||||
QString failure;
|
||||
};
|
||||
|
||||
struct Prepared {
|
||||
ProjectSnapshot snapshot;
|
||||
QString destination;
|
||||
QUuid revision;
|
||||
};
|
||||
|
||||
bool begin();
|
||||
void finish(const std::function<void(bool)> &done, bool value);
|
||||
void saveCurrent(bool asNew, std::function<void(bool)> then);
|
||||
void write(const ProjectSnapshot &snapshot, const QString &destination, std::function<void(bool)> then);
|
||||
// Swift's prepareSave: the document now, and where it goes.
|
||||
void prepareSave(bool asNew, std::function<void(std::optional<Prepared>)> then);
|
||||
void write(const Prepared &prepared, std::function<void(bool)> then);
|
||||
// A save still writing finishes first; then `then` runs.
|
||||
void finishWriting(std::function<void()> then);
|
||||
void load(const QString &path, std::function<void(Loaded)> then);
|
||||
void confirmReplacement(std::function<void(bool)> then);
|
||||
void showError(const QString &title, const QString &message, std::function<void()> then);
|
||||
@@ -96,6 +106,9 @@ private:
|
||||
void askToRevert(std::function<void(bool)> then);
|
||||
|
||||
int m_saveGeneration = 0;
|
||||
// Swift's `writing`: a save writes in the background.
|
||||
bool m_writing = false;
|
||||
std::vector<std::function<void()>> m_writeWaiters;
|
||||
std::deque<Incoming> m_incoming;
|
||||
bool m_processing = false;
|
||||
};
|
||||
@@ -0,0 +1,370 @@
|
||||
#include "SaveHooks.h"
|
||||
#include "UI/ColorPickerSheet.h"
|
||||
|
||||
// A write held mid-save: what waits, what goes beside.
|
||||
class ProjectSaveOrderTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void init();
|
||||
void cleanup();
|
||||
void aReopenReadsNothingWhileTheWriteIsHeld();
|
||||
void aRevertAnsweredMidSaveLoadsWhatWeWrote();
|
||||
void theGuardLastsThroughTheSavesDigest();
|
||||
void canvasSizeWorksBesideAHeldWrite();
|
||||
void imageSizeWorksBesideAHeldWrite();
|
||||
void aDigestOutWhenOurSaveBeginsIsDropped();
|
||||
void aFailedSaveKeepsAnExternalChangeInLine();
|
||||
void aSecondSaveWaitsThroughTheFirstDigest();
|
||||
void canvasSizeCommittedBeforeTheSaveLands();
|
||||
void imageSizeCommittedAfterTheSaveLands();
|
||||
void keepMineAfterASaveKeepsTheSavedDigest();
|
||||
};
|
||||
|
||||
void ProjectSaveOrderTests::init()
|
||||
{
|
||||
resetHooks();
|
||||
}
|
||||
|
||||
void ProjectSaveOrderTests::cleanup()
|
||||
{
|
||||
resetHooks();
|
||||
}
|
||||
|
||||
void ProjectSaveOrderTests::aReopenReadsNothingWhileTheWriteIsHeld()
|
||||
{
|
||||
QTemporaryDir root;
|
||||
const QString path = savedProject(root);
|
||||
EditorSession session;
|
||||
const auto controller = opened(session, path);
|
||||
session.renameLayer(session.document().value().layers[0].id, QStringLiteral("Latest saved version"));
|
||||
holdSwap = true;
|
||||
std::optional<bool> saved, reopened;
|
||||
controller->save(false, [&](bool value) { saved = value; });
|
||||
const int before = loads;
|
||||
controller->open(path, [&](bool value) { reopened = value; });
|
||||
QTest::qWait(400);
|
||||
// Held mid-save: the open has not read the package.
|
||||
QCOMPARE(loads.load(), before);
|
||||
QVERIFY(!reopened.has_value());
|
||||
holdSwap = false;
|
||||
QTRY_VERIFY_WITH_TIMEOUT(saved.has_value() && reopened.has_value(), 10000);
|
||||
QCOMPARE(firstName(session), QString("Latest saved version"));
|
||||
QVERIFY(!session.isModified());
|
||||
}
|
||||
|
||||
void ProjectSaveOrderTests::aRevertAnsweredMidSaveLoadsWhatWeWrote()
|
||||
{
|
||||
QTemporaryDir root;
|
||||
const QString path = savedProject(root);
|
||||
EditorSession session;
|
||||
const auto controller = opened(session, path);
|
||||
QWidget window;
|
||||
window.show();
|
||||
controller->window = &window;
|
||||
session.renameLayer(session.document().value().layers[0].id, QStringLiteral("Ours"));
|
||||
std::optional<bool> saved;
|
||||
int loadsWhileHeld = -1;
|
||||
DialogDesk desk;
|
||||
desk.replies = {"Revert"};
|
||||
// With the question up, we save; the write is held.
|
||||
desk.note = [&] {
|
||||
holdSwap = true;
|
||||
controller->save(false, [&](bool value) { saved = value; });
|
||||
const int before = loads;
|
||||
QTimer::singleShot(400, [&, before] {
|
||||
loadsWhileHeld = loads - before;
|
||||
holdSwap = false;
|
||||
});
|
||||
return QString();
|
||||
};
|
||||
renameFirstLayerOnDisk(path, QStringLiteral("Theirs"));
|
||||
QTRY_VERIFY_WITH_TIMEOUT(saved.has_value(), 10000);
|
||||
QTRY_COMPARE_WITH_TIMEOUT(controller->externalChanges.reloadCount, 1, 10000);
|
||||
QCOMPARE(loadsWhileHeld, 0);
|
||||
QCOMPARE(firstName(session), QString("Ours"));
|
||||
QVERIFY(!session.isModified());
|
||||
QCOMPARE(controller->externalChanges.knownDigest.value(), ProjectDigest::compute(path));
|
||||
}
|
||||
|
||||
void ProjectSaveOrderTests::theGuardLastsThroughTheSavesDigest()
|
||||
{
|
||||
QTemporaryDir root;
|
||||
const QString path = savedProject(root);
|
||||
EditorSession session;
|
||||
const auto controller = opened(session, path);
|
||||
session.renameLayer(session.document().value().layers[0].id, QStringLiteral("Saved"));
|
||||
// Just after the path is announced, the digest is out.
|
||||
std::optional<bool> guarded;
|
||||
QObject::connect(&session, &EditorSession::changed, [&] {
|
||||
if (guarded.has_value() || session.isModified())
|
||||
return;
|
||||
QMetaObject::invokeMethod(controller.get(), [&] { guarded = controller->externalChanges.saving; }, Qt::QueuedConnection);
|
||||
});
|
||||
QVERIFY(answered([&](auto done) { controller->save(false, done); }));
|
||||
QCOMPARE(guarded, std::optional<bool>(true));
|
||||
QVERIFY(!controller->externalChanges.saving);
|
||||
}
|
||||
|
||||
void ProjectSaveOrderTests::canvasSizeWorksBesideAHeldWrite()
|
||||
{
|
||||
QTemporaryDir root;
|
||||
const QString path = savedProject(root);
|
||||
EditorSession session;
|
||||
const auto controller = opened(session, path);
|
||||
QWidget window;
|
||||
window.show();
|
||||
controller->window = &window;
|
||||
session.renameLayer(session.document().value().layers[0].id, QStringLiteral("Captured"));
|
||||
holdSwap = true;
|
||||
std::optional<bool> saved;
|
||||
controller->save(false, [&](bool value) { saved = value; });
|
||||
// The sheet opens beside the held write, holding the tools.
|
||||
bool resized = false;
|
||||
controller->canvasSize([&] { resized = true; });
|
||||
QTRY_VERIFY(window.findChild<QDialog *>());
|
||||
QVERIFY(session.isProjectBusy());
|
||||
holdSwap = false;
|
||||
QTRY_VERIFY_WITH_TIMEOUT(saved.has_value(), 10000);
|
||||
// Saved with the sheet still up: the tools stay held.
|
||||
QVERIFY(session.isProjectBusy());
|
||||
auto *width = window.findChild<QDialog *>()->findChild<PickerField *>(QStringLiteral("canvasWidth"));
|
||||
width->setFocus();
|
||||
width->selectAll();
|
||||
QTest::keyClicks(width, QStringLiteral("20"));
|
||||
QTest::keyClick(width, Qt::Key_Return);
|
||||
QTRY_VERIFY_WITH_TIMEOUT(resized, 10000);
|
||||
QCOMPARE(session.document().value().width, 20);
|
||||
QCOMPARE(ProjectStore::load(path).manifest.width, qint64(8));
|
||||
QVERIFY(session.isModified());
|
||||
session.undo();
|
||||
QVERIFY(!session.isModified());
|
||||
QCOMPARE(session.document().value().width, 8);
|
||||
}
|
||||
|
||||
void ProjectSaveOrderTests::imageSizeWorksBesideAHeldWrite()
|
||||
{
|
||||
QTemporaryDir root;
|
||||
const QString path = savedProject(root);
|
||||
EditorSession session;
|
||||
const auto controller = opened(session, path);
|
||||
QWidget window;
|
||||
window.show();
|
||||
controller->window = &window;
|
||||
session.renameLayer(session.document().value().layers[0].id, QStringLiteral("Captured"));
|
||||
holdSwap = true;
|
||||
std::optional<bool> saved;
|
||||
controller->save(false, [&](bool value) { saved = value; });
|
||||
bool resized = false;
|
||||
controller->imageSize([&] { resized = true; });
|
||||
QTRY_VERIFY(window.findChild<QDialog *>());
|
||||
// Resized before the write lands: the capture stays.
|
||||
auto *width = window.findChild<QDialog *>()->findChild<PickerField *>(QStringLiteral("imageWidth"));
|
||||
width->setFocus();
|
||||
width->selectAll();
|
||||
QTest::keyClicks(width, QStringLiteral("16"));
|
||||
QTest::keyClick(width, Qt::Key_Return);
|
||||
QTRY_VERIFY_WITH_TIMEOUT(resized, 10000);
|
||||
QCOMPARE(session.document().value().width, 16);
|
||||
QCOMPARE(session.document().value().height, 12);
|
||||
QVERIFY(!saved.has_value());
|
||||
holdSwap = false;
|
||||
QTRY_VERIFY_WITH_TIMEOUT(saved.has_value(), 10000);
|
||||
QVERIFY(saved.value());
|
||||
QCOMPARE(ProjectStore::load(path).manifest.width, qint64(8));
|
||||
QVERIFY(session.isModified());
|
||||
session.undo();
|
||||
QVERIFY(!session.isModified());
|
||||
}
|
||||
|
||||
void ProjectSaveOrderTests::aDigestOutWhenOurSaveBeginsIsDropped()
|
||||
{
|
||||
QTemporaryDir root;
|
||||
const QString path = savedProject(root);
|
||||
EditorSession session;
|
||||
const auto controller = opened(session, path);
|
||||
// The check's digest waits at the images listing.
|
||||
heldListing = QFile::encodeName(QFileInfo(path + QStringLiteral("/images")).canonicalFilePath());
|
||||
holdListing = true;
|
||||
renameFirstLayerOnDisk(path, QStringLiteral("Theirs"));
|
||||
QTRY_VERIFY_WITH_TIMEOUT(listingsHeld > 0, 4000);
|
||||
// Our save starts, held at its swap; the digest returns.
|
||||
holdSwap = true;
|
||||
std::optional<bool> saved;
|
||||
controller->save(false, [&](bool value) { saved = value; });
|
||||
QVERIFY(controller->externalChanges.saving);
|
||||
holdListing = false;
|
||||
QTest::qWait(400);
|
||||
QVERIFY(!controller->externalChanges.checking);
|
||||
holdSwap = false;
|
||||
QTRY_VERIFY_WITH_TIMEOUT(saved.has_value(), 10000);
|
||||
settle();
|
||||
// What it read is ours now: no reload, nothing asked.
|
||||
QCOMPARE(controller->externalChanges.reloadCount, 0);
|
||||
QCOMPARE(firstName(session), QString("Base"));
|
||||
QCOMPARE(ProjectStore::load(path).manifest.layers[0].name, QString("Base"));
|
||||
QCOMPARE(controller->externalChanges.knownDigest.value(), ProjectDigest::compute(path));
|
||||
}
|
||||
|
||||
void ProjectSaveOrderTests::keepMineAfterASaveKeepsTheSavedDigest()
|
||||
{
|
||||
QTemporaryDir root;
|
||||
const QString path = savedProject(root);
|
||||
EditorSession session;
|
||||
const auto controller = opened(session, path);
|
||||
QWidget window;
|
||||
window.show();
|
||||
controller->window = &window;
|
||||
session.renameLayer(session.document().value().layers[0].id, QStringLiteral("Ours"));
|
||||
DialogDesk desk;
|
||||
desk.replies = {"Keep Mine"};
|
||||
// With the question up, our save lands first.
|
||||
desk.note = [&] {
|
||||
std::optional<bool> saved;
|
||||
controller->save(false, [&](bool value) { saved = value; });
|
||||
if (!QTest::qWaitFor([&] { return saved.has_value(); }, 10000))
|
||||
return QStringLiteral("saved=never");
|
||||
return QStringLiteral("saved=%1").arg(saved.value_or(false));
|
||||
};
|
||||
renameFirstLayerOnDisk(path, QStringLiteral("Theirs"));
|
||||
QTRY_COMPARE_WITH_TIMEOUT(desk.seen.size(), 1, 6000);
|
||||
QVERIFY2(desk.seen[0].endsWith("|saved=1"), qPrintable(desk.seen[0]));
|
||||
settle();
|
||||
QCOMPARE(controller->externalChanges.knownDigest.value(), ProjectDigest::compute(path));
|
||||
// A touch of our own package changes nothing.
|
||||
const QString manifest = path + QStringLiteral("/manifest.json");
|
||||
rewrite(manifest, contents(manifest));
|
||||
settle();
|
||||
QCOMPARE(controller->externalChanges.reloadCount, 0);
|
||||
QCOMPARE(desk.seen.size(), 1);
|
||||
QCOMPARE(firstName(session), QString("Ours"));
|
||||
}
|
||||
|
||||
void ProjectSaveOrderTests::aFailedSaveKeepsAnExternalChangeInLine()
|
||||
{
|
||||
QTemporaryDir root;
|
||||
const QString path = savedProject(root);
|
||||
EditorSession session;
|
||||
const auto controller = opened(session, path);
|
||||
heldListing = QFile::encodeName(QFileInfo(path + QStringLiteral("/images")).canonicalFilePath());
|
||||
holdListing = true;
|
||||
renameFirstLayerOnDisk(path, QStringLiteral("External version"));
|
||||
QTRY_VERIFY_WITH_TIMEOUT(listingsHeld > 0, 4000);
|
||||
// Our save fails; the digest returns under its alert.
|
||||
QVERIFY(QFile::setPermissions(root.path(), QFileDevice::ReadOwner | QFileDevice::ExeOwner));
|
||||
DialogDesk desk;
|
||||
desk.replies = {"OK"};
|
||||
desk.note = [&] {
|
||||
holdListing = false;
|
||||
QTest::qWait(400);
|
||||
return QStringLiteral("saving=%1").arg(controller->externalChanges.saving);
|
||||
};
|
||||
std::optional<bool> saved;
|
||||
controller->save(false, [&](bool value) { saved = value; });
|
||||
QTRY_VERIFY_WITH_TIMEOUT(saved.has_value(), 10000);
|
||||
QVERIFY(!saved.value());
|
||||
QVERIFY2(desk.seen.value(0).endsWith("|saving=1"), qPrintable(desk.seen.value(0)));
|
||||
QFile::setPermissions(root.path(), QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner);
|
||||
// After the failure, the change it saw is taken up.
|
||||
QTRY_COMPARE_WITH_TIMEOUT(controller->externalChanges.reloadCount, 1, 6000);
|
||||
QCOMPARE(firstName(session), QString("External version"));
|
||||
QVERIFY(!controller->externalChanges.pending);
|
||||
}
|
||||
|
||||
void ProjectSaveOrderTests::aSecondSaveWaitsThroughTheFirstDigest()
|
||||
{
|
||||
QTemporaryDir root;
|
||||
const QString path = savedProject(root);
|
||||
EditorSession session;
|
||||
const auto controller = opened(session, path);
|
||||
const QUuid base = session.document().value().layers[0].id;
|
||||
// The first save's digest waits at the images listing.
|
||||
heldListing = QFile::encodeName(QFileInfo(path + QStringLiteral("/images")).canonicalFilePath());
|
||||
holdListing = true;
|
||||
session.renameLayer(base, QStringLiteral("First"));
|
||||
std::optional<bool> first, second;
|
||||
controller->save(false, [&](bool value) { first = value; });
|
||||
QTRY_VERIFY_WITH_TIMEOUT(swaps == 1 && listingsHeld > 0, 6000);
|
||||
session.renameLayer(base, QStringLiteral("Second"));
|
||||
controller->save(false, [&](bool value) { second = value; });
|
||||
QTest::qWait(400);
|
||||
QCOMPARE(swaps.load(), 1);
|
||||
QVERIFY(!first.has_value() && !second.has_value());
|
||||
holdListing = false;
|
||||
QTRY_VERIFY_WITH_TIMEOUT(first.has_value() && second.has_value(), 10000);
|
||||
QVERIFY(first.value() && second.value());
|
||||
QCOMPARE(swaps.load(), 2);
|
||||
QCOMPARE(ProjectStore::load(path).manifest.layers[0].name, QString("Second"));
|
||||
}
|
||||
|
||||
void ProjectSaveOrderTests::canvasSizeCommittedBeforeTheSaveLands()
|
||||
{
|
||||
QTemporaryDir root;
|
||||
const QString path = savedProject(root);
|
||||
EditorSession session;
|
||||
const auto controller = opened(session, path);
|
||||
QWidget window;
|
||||
window.show();
|
||||
controller->window = &window;
|
||||
session.renameLayer(session.document().value().layers[0].id, QStringLiteral("Captured"));
|
||||
holdSwap = true;
|
||||
std::optional<bool> saved;
|
||||
controller->save(false, [&](bool value) { saved = value; });
|
||||
bool resized = false;
|
||||
controller->canvasSize([&] { resized = true; });
|
||||
QTRY_VERIFY(window.findChild<QDialog *>());
|
||||
auto *width = window.findChild<QDialog *>()->findChild<PickerField *>(QStringLiteral("canvasWidth"));
|
||||
width->setFocus();
|
||||
width->selectAll();
|
||||
QTest::keyClicks(width, QStringLiteral("16"));
|
||||
QTest::keyClick(width, Qt::Key_Return);
|
||||
QTRY_VERIFY_WITH_TIMEOUT(resized, 10000);
|
||||
QCOMPARE(session.document().value().width, 16);
|
||||
QVERIFY(!saved.has_value());
|
||||
holdSwap = false;
|
||||
QTRY_VERIFY_WITH_TIMEOUT(saved.has_value(), 10000);
|
||||
QVERIFY(saved.value());
|
||||
QCOMPARE(ProjectStore::load(path).manifest.width, qint64(8));
|
||||
QVERIFY(session.isModified());
|
||||
session.undo();
|
||||
QVERIFY(!session.isModified());
|
||||
QCOMPARE(session.document().value().width, 8);
|
||||
}
|
||||
|
||||
void ProjectSaveOrderTests::imageSizeCommittedAfterTheSaveLands()
|
||||
{
|
||||
QTemporaryDir root;
|
||||
const QString path = savedProject(root);
|
||||
EditorSession session;
|
||||
const auto controller = opened(session, path);
|
||||
QWidget window;
|
||||
window.show();
|
||||
controller->window = &window;
|
||||
session.renameLayer(session.document().value().layers[0].id, QStringLiteral("Captured"));
|
||||
holdSwap = true;
|
||||
std::optional<bool> saved;
|
||||
controller->save(false, [&](bool value) { saved = value; });
|
||||
bool resized = false;
|
||||
controller->imageSize([&] { resized = true; });
|
||||
QTRY_VERIFY(window.findChild<QDialog *>());
|
||||
holdSwap = false;
|
||||
// Saved with the sheet still up: the tools stay held.
|
||||
QTRY_VERIFY_WITH_TIMEOUT(saved.has_value(), 10000);
|
||||
QVERIFY(saved.value());
|
||||
QVERIFY(session.isProjectBusy());
|
||||
QVERIFY(!session.isModified());
|
||||
auto *width = window.findChild<QDialog *>()->findChild<PickerField *>(QStringLiteral("imageWidth"));
|
||||
width->setFocus();
|
||||
width->selectAll();
|
||||
QTest::keyClicks(width, QStringLiteral("16"));
|
||||
QTest::keyClick(width, Qt::Key_Return);
|
||||
QTRY_VERIFY_WITH_TIMEOUT(resized, 10000);
|
||||
QCOMPARE(session.document().value().width, 16);
|
||||
QCOMPARE(ProjectStore::load(path).manifest.width, qint64(8));
|
||||
QVERIFY(session.isModified());
|
||||
session.undo();
|
||||
QVERIFY(!session.isModified());
|
||||
QCOMPARE(session.document().value().width, 8);
|
||||
}
|
||||
|
||||
QTEST_MAIN(ProjectSaveOrderTests)
|
||||
#include "ProjectSaveOrderTests.moc"
|
||||
@@ -0,0 +1,239 @@
|
||||
#include "SaveHooks.h"
|
||||
|
||||
// Saves queued behind a write: one writer at a time.
|
||||
class ProjectSaveQueueTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void init();
|
||||
void cleanup();
|
||||
void threeSavesWriteOneAfterAnother();
|
||||
void aSaveAskedAsTheToolsFreeWaitsItsTurn();
|
||||
void aQuitWaitsForTheWriteBeforeAsking();
|
||||
void aQueuedSaveWithoutADocumentSavesNothing();
|
||||
void aFailedWriteHoldsItsWaitersUntilAnswered();
|
||||
void reopeningWhileSavingReadsTheSavedPackage();
|
||||
void closingATabWaitsForItsWrite();
|
||||
void aControllerGoneMidWriteLeavesNoCallback();
|
||||
void anExportDuringAWriteSucceedsBeside();
|
||||
};
|
||||
|
||||
void ProjectSaveQueueTests::init()
|
||||
{
|
||||
resetHooks();
|
||||
}
|
||||
|
||||
void ProjectSaveQueueTests::cleanup()
|
||||
{
|
||||
resetHooks();
|
||||
}
|
||||
|
||||
void ProjectSaveQueueTests::threeSavesWriteOneAfterAnother()
|
||||
{
|
||||
QTemporaryDir root;
|
||||
const QString path = savedProject(root);
|
||||
EditorSession session;
|
||||
const auto controller = opened(session, path);
|
||||
const QUuid base = session.document().value().layers[0].id;
|
||||
QStringList order;
|
||||
for (const QString &name : {QStringLiteral("A"), QStringLiteral("B"), QStringLiteral("C")}) {
|
||||
session.renameLayer(base, name);
|
||||
controller->save(false, [&, name](bool saved) {
|
||||
order << QStringLiteral("%1=%2 disk=%3").arg(name).arg(saved).arg(ProjectStore::load(path).manifest.layers[0].name);
|
||||
});
|
||||
}
|
||||
session.renameLayer(base, QStringLiteral("Last"));
|
||||
QTRY_COMPARE_WITH_TIMEOUT(order.size(), 3, 10000);
|
||||
// A captured itself; the rest waited and captured the latest.
|
||||
QCOMPARE(order, (QStringList{"A=1 disk=A", "B=1 disk=Last", "C=1 disk=Last"}));
|
||||
QCOMPARE(mostAtOnce.load(), 1);
|
||||
QVERIFY(!session.isModified());
|
||||
QVERIFY(!controller->externalChanges.saving);
|
||||
}
|
||||
|
||||
void ProjectSaveQueueTests::aSaveAskedAsTheToolsFreeWaitsItsTurn()
|
||||
{
|
||||
QTemporaryDir root;
|
||||
const QString path = savedProject(root);
|
||||
EditorSession session;
|
||||
const auto controller = opened(session, path);
|
||||
session.renameLayer(session.document().value().layers[0].id, QStringLiteral("First"));
|
||||
// The moment the tools go free, an observer saves again.
|
||||
std::optional<bool> first, second;
|
||||
bool asked = false, wasBusy = false, reserved = false;
|
||||
QObject::connect(&session, &EditorSession::changed, [&] {
|
||||
const bool freed = wasBusy && !session.isProjectBusy();
|
||||
wasBusy = session.isProjectBusy();
|
||||
if (asked || !freed)
|
||||
return;
|
||||
asked = true;
|
||||
reserved = controller->externalChanges.saving;
|
||||
controller->save(false, [&](bool saved) { second = saved; });
|
||||
});
|
||||
controller->save(false, [&](bool saved) { first = saved; });
|
||||
QTRY_VERIFY_WITH_TIMEOUT(first.has_value() && second.has_value(), 10000);
|
||||
QVERIFY(asked);
|
||||
QVERIFY(reserved);
|
||||
QVERIFY(first.value() && second.value());
|
||||
QCOMPARE(mostAtOnce.load(), 1);
|
||||
}
|
||||
|
||||
void ProjectSaveQueueTests::aQuitWaitsForTheWriteBeforeAsking()
|
||||
{
|
||||
QTemporaryDir root;
|
||||
const QString path = savedProject(root);
|
||||
EditorSession session;
|
||||
const auto controller = opened(session, path);
|
||||
QWidget window;
|
||||
window.show();
|
||||
controller->window = &window;
|
||||
const QUuid base = session.document().value().layers[0].id;
|
||||
QStringList order;
|
||||
session.renameLayer(base, QStringLiteral("One"));
|
||||
controller->save(false, [&](bool saved) { order << QStringLiteral("one=%1").arg(saved); });
|
||||
session.renameLayer(base, QStringLiteral("Two"));
|
||||
// The quit asks only once the write is whole.
|
||||
DialogDesk desk;
|
||||
desk.replies = {"Don’t Save"};
|
||||
desk.note = [&] { return QStringLiteral("disk=%1 saving=%2").arg(ProjectStore::load(path).manifest.layers[0].name).arg(controller->externalChanges.saving); };
|
||||
controller->confirmQuit([&](bool quits) { order << QStringLiteral("quit=%1").arg(quits); });
|
||||
QTRY_COMPARE_WITH_TIMEOUT(order.size(), 2, 10000);
|
||||
QCOMPARE(order, (QStringList{"one=1", "quit=1"}));
|
||||
QCOMPARE(desk.seen.size(), 1);
|
||||
QVERIFY2(desk.seen[0].endsWith("|disk=One saving=0"), qPrintable(desk.seen[0]));
|
||||
}
|
||||
|
||||
void ProjectSaveQueueTests::aQueuedSaveWithoutADocumentSavesNothing()
|
||||
{
|
||||
QTemporaryDir root;
|
||||
EditorSession session;
|
||||
ProjectController controller(session);
|
||||
session.createDocument(8, 6);
|
||||
session.addBlankLayer();
|
||||
const QString path = root.filePath(QStringLiteral("New.comp"));
|
||||
DialogDesk desk;
|
||||
desk.replies = {path};
|
||||
std::optional<bool> first, second;
|
||||
controller.save(false, [&](bool saved) { first = saved; });
|
||||
QTRY_VERIFY_WITH_TIMEOUT(controller.externalChanges.saving, 4000);
|
||||
controller.save(false, [&](bool saved) { second = saved; });
|
||||
// Undo takes the canvas back while the first write runs.
|
||||
while (session.document() && session.canUndo())
|
||||
session.undo();
|
||||
QVERIFY(!session.document());
|
||||
QTRY_VERIFY_WITH_TIMEOUT(first.has_value() && second.has_value(), 10000);
|
||||
QVERIFY(first.value());
|
||||
QVERIFY(!second.value());
|
||||
QVERIFY(!session.isProjectBusy());
|
||||
QCOMPARE(desk.seen.size(), 1);
|
||||
}
|
||||
|
||||
void ProjectSaveQueueTests::aFailedWriteHoldsItsWaitersUntilAnswered()
|
||||
{
|
||||
QTemporaryDir root;
|
||||
const QString path = savedProject(root);
|
||||
EditorSession session;
|
||||
const auto controller = opened(session, path);
|
||||
const QString locked = root.filePath(QStringLiteral("Locked"));
|
||||
QVERIFY(QDir().mkpath(locked));
|
||||
QVERIFY(QFile::setPermissions(locked, QFileDevice::ReadOwner | QFileDevice::ExeOwner));
|
||||
session.renameLayer(session.document().value().layers[0].id, QStringLiteral("Unsaved"));
|
||||
std::optional<bool> failed, again, quit;
|
||||
DialogDesk desk;
|
||||
desk.replies = {locked + QStringLiteral("/Copy.comp"), "OK", "Don’t Save"};
|
||||
// With the failure's alert up, a save and quit queue.
|
||||
desk.note = [&] {
|
||||
if (desk.seen.size() != 1)
|
||||
return QString();
|
||||
controller->save(false, [&](bool saved) { again = saved; });
|
||||
controller->confirmQuit([&](bool quits) { quit = quits; });
|
||||
QTest::qWait(300);
|
||||
return QStringLiteral("again=%1 quit=%2").arg(again.has_value()).arg(quit.has_value());
|
||||
};
|
||||
controller->save(true, [&](bool saved) { failed = saved; });
|
||||
QTRY_VERIFY_WITH_TIMEOUT(failed.has_value() && again.has_value() && quit.has_value(), 10000);
|
||||
QVERIFY2(desk.seen.value(1).endsWith("|again=0 quit=0"), qPrintable(desk.seen.value(1)));
|
||||
QVERIFY(!failed.value());
|
||||
// The quit took the tools first: the save is refused.
|
||||
QVERIFY(!again.value());
|
||||
QVERIFY(quit.value());
|
||||
QCOMPARE(desk.seen.size(), 3);
|
||||
QCOMPARE(ProjectStore::load(path).manifest.layers[0].name, QString("Base"));
|
||||
QFile::setPermissions(locked, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner);
|
||||
}
|
||||
|
||||
void ProjectSaveQueueTests::reopeningWhileSavingReadsTheSavedPackage()
|
||||
{
|
||||
QTemporaryDir root;
|
||||
const QString path = savedProject(root);
|
||||
EditorSession session;
|
||||
const auto controller = opened(session, path);
|
||||
session.renameLayer(session.document().value().layers[0].id, QStringLiteral("Newest saved version"));
|
||||
std::optional<bool> saved, reopened;
|
||||
controller->save(false, [&](bool value) { saved = value; });
|
||||
controller->open(path, [&](bool value) { reopened = value; });
|
||||
QTRY_VERIFY_WITH_TIMEOUT(saved.has_value() && reopened.has_value(), 10000);
|
||||
QVERIFY(saved.value() && reopened.value());
|
||||
QCOMPARE(firstName(session), QString("Newest saved version"));
|
||||
QVERIFY(!session.isModified());
|
||||
}
|
||||
|
||||
void ProjectSaveQueueTests::closingATabWaitsForItsWrite()
|
||||
{
|
||||
QTemporaryDir root;
|
||||
const QString path = savedProject(root);
|
||||
ProjectWorkspace workspace;
|
||||
QVERIFY(answered([&](auto done) { workspace.open(path, done); }));
|
||||
const std::shared_ptr<ProjectTab> tab = workspace.tabs().back();
|
||||
tab->session.renameLayer(tab->session.document().value().layers[0].id, QStringLiteral("Written"));
|
||||
std::optional<bool> saved;
|
||||
tab->controller.save(false, [&](bool value) { saved = value; });
|
||||
QVERIFY(tab->controller.externalChanges.saving);
|
||||
bool closed = false;
|
||||
workspace.close(tab->id, [&] { closed = true; });
|
||||
// The tab stays until its package is whole.
|
||||
QTRY_VERIFY_WITH_TIMEOUT(closed, 10000);
|
||||
QVERIFY(saved.value());
|
||||
QVERIFY(std::none_of(workspace.tabs().begin(), workspace.tabs().end(), [&](const auto &each) { return each->id == tab->id; }));
|
||||
QCOMPARE(ProjectStore::load(path).manifest.layers[0].name, QString("Written"));
|
||||
}
|
||||
|
||||
void ProjectSaveQueueTests::aControllerGoneMidWriteLeavesNoCallback()
|
||||
{
|
||||
QTemporaryDir root;
|
||||
const QString path = savedProject(root);
|
||||
EditorSession session;
|
||||
auto controller = opened(session, path);
|
||||
session.renameLayer(session.document().value().layers[0].id, QStringLiteral("Written"));
|
||||
bool first = false, second = false;
|
||||
controller->save(false, [&](bool) { first = true; });
|
||||
controller->save(false, [&](bool) { second = true; });
|
||||
controller.reset();
|
||||
QTest::qWait(1500);
|
||||
QVERIFY(!first && !second);
|
||||
// The worker finished its package all the same.
|
||||
QCOMPARE(ProjectStore::load(path).manifest.layers[0].name, QString("Written"));
|
||||
}
|
||||
|
||||
void ProjectSaveQueueTests::anExportDuringAWriteSucceedsBeside()
|
||||
{
|
||||
QTemporaryDir root;
|
||||
const QString path = savedProject(root);
|
||||
EditorSession session;
|
||||
const auto controller = opened(session, path);
|
||||
session.renameLayer(session.document().value().layers[0].id, QStringLiteral("Written"));
|
||||
std::optional<bool> saved;
|
||||
controller->save(false, [&](bool value) { saved = value; });
|
||||
// The tools are free: an export starts beside the write.
|
||||
const QString png = root.filePath(QStringLiteral("Picture.png"));
|
||||
DialogDesk desk;
|
||||
desk.replies = {png};
|
||||
bool exported = false;
|
||||
controller->exportPNG([&] { exported = true; });
|
||||
QTRY_VERIFY_WITH_TIMEOUT(exported && saved.has_value(), 10000);
|
||||
QVERIFY(saved.value());
|
||||
QVERIFY(QFile::exists(png));
|
||||
QCOMPARE(ProjectStore::load(path).manifest.layers[0].name, QString("Written"));
|
||||
}
|
||||
|
||||
QTEST_MAIN(ProjectSaveQueueTests)
|
||||
#include "ProjectSaveQueueTests.moc"
|
||||
@@ -0,0 +1,144 @@
|
||||
#include "ExternalChangeFixtures.h"
|
||||
|
||||
// Swift's 940e188: editing goes on while a save writes.
|
||||
class ProjectSaveWhileEditingTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void editsMadeWhileWritingStayUnsaved();
|
||||
void aSecondSaveWaitsForTheFirst();
|
||||
void aNewCanvasWaitsForTheWriteBeforeAsking();
|
||||
void onlyTheCaptureAndPanelHoldTheTools();
|
||||
void aFailedWriteLeavesTheEditUnsaved();
|
||||
void theLastSignalShowsTheSavedState();
|
||||
};
|
||||
|
||||
void ProjectSaveWhileEditingTests::editsMadeWhileWritingStayUnsaved()
|
||||
{
|
||||
QTemporaryDir root;
|
||||
const QString path = savedProject(root);
|
||||
EditorSession session;
|
||||
const auto controller = opened(session, path);
|
||||
const QUuid base = session.document().value().layers[0].id;
|
||||
session.renameLayer(base, QStringLiteral("Captured"));
|
||||
std::optional<bool> saved;
|
||||
controller->save(false, [&](bool value) { saved = value; });
|
||||
// Captured: the tools are free while it writes.
|
||||
QVERIFY(!session.isProjectBusy());
|
||||
QVERIFY(controller->externalChanges.saving);
|
||||
session.renameLayer(base, QStringLiteral("After the capture"));
|
||||
QTRY_VERIFY(saved.has_value());
|
||||
QVERIFY(saved.value());
|
||||
QCOMPARE(ProjectStore::load(path).manifest.layers[0].name, QString("Captured"));
|
||||
QVERIFY(session.isModified());
|
||||
// Undoing back to the saved version is unmodified again.
|
||||
session.undo();
|
||||
QCOMPARE(firstName(session), QString("Captured"));
|
||||
QVERIFY(!session.isModified());
|
||||
}
|
||||
|
||||
void ProjectSaveWhileEditingTests::aSecondSaveWaitsForTheFirst()
|
||||
{
|
||||
QTemporaryDir root;
|
||||
const QString path = savedProject(root);
|
||||
EditorSession session;
|
||||
const auto controller = opened(session, path);
|
||||
const QUuid base = session.document().value().layers[0].id;
|
||||
session.renameLayer(base, QStringLiteral("First"));
|
||||
QStringList order;
|
||||
controller->save(false, [&](bool saved) { order << QStringLiteral("first=%1").arg(saved); });
|
||||
session.renameLayer(base, QStringLiteral("Second"));
|
||||
controller->save(false, [&](bool saved) {
|
||||
order << QStringLiteral("second=%1 name=%2").arg(saved).arg(ProjectStore::load(path).manifest.layers[0].name);
|
||||
});
|
||||
// Waiting, the second save holds nothing.
|
||||
QVERIFY(!session.isProjectBusy());
|
||||
QTRY_COMPARE(order.size(), 2);
|
||||
QCOMPARE(order, (QStringList{"first=1", "second=1 name=Second"}));
|
||||
QVERIFY(!session.isModified());
|
||||
}
|
||||
|
||||
void ProjectSaveWhileEditingTests::aNewCanvasWaitsForTheWriteBeforeAsking()
|
||||
{
|
||||
QTemporaryDir root;
|
||||
const QString path = savedProject(root);
|
||||
EditorSession session;
|
||||
const auto controller = opened(session, path);
|
||||
QWidget window;
|
||||
window.show();
|
||||
controller->window = &window;
|
||||
const QUuid base = session.document().value().layers[0].id;
|
||||
session.renameLayer(base, QStringLiteral("Written"));
|
||||
DialogDesk desk;
|
||||
desk.replies = {"Don’t Save"};
|
||||
desk.note = [&] { return QStringLiteral("saving=%1").arg(controller->externalChanges.saving); };
|
||||
std::optional<bool> saved;
|
||||
controller->save(false, [&](bool value) { saved = value; });
|
||||
session.renameLayer(base, QStringLiteral("Unsaved"));
|
||||
controller->newCanvas();
|
||||
// The question comes once the file is whole.
|
||||
QTRY_VERIFY(!session.document());
|
||||
QCOMPARE(desk.seen.size(), 1);
|
||||
QVERIFY2(desk.seen[0].startsWith("alert|2|Save changes to Watched.comp?") && desk.seen[0].endsWith("|saving=0"), qPrintable(desk.seen[0]));
|
||||
QVERIFY(saved.value());
|
||||
QCOMPARE(ProjectStore::load(path).manifest.layers[0].name, QString("Written"));
|
||||
}
|
||||
|
||||
void ProjectSaveWhileEditingTests::onlyTheCaptureAndPanelHoldTheTools()
|
||||
{
|
||||
QTemporaryDir root;
|
||||
const QString path = savedProject(root);
|
||||
EditorSession session;
|
||||
const auto controller = opened(session, path);
|
||||
DialogDesk desk;
|
||||
const QString copy = root.filePath(QStringLiteral("Copy.comp"));
|
||||
desk.replies = {copy};
|
||||
desk.note = [&] { return session.isProjectBusy() ? QStringLiteral("busy") : QStringLiteral("free"); };
|
||||
std::optional<bool> saved;
|
||||
bool busyWhileWriting = false;
|
||||
QObject::connect(&session, &EditorSession::changed, [&] {
|
||||
busyWhileWriting = busyWhileWriting || (controller->externalChanges.saving && session.isProjectBusy());
|
||||
});
|
||||
controller->save(true, [&](bool value) { saved = value; });
|
||||
QTRY_VERIFY(saved.has_value());
|
||||
QVERIFY(saved.value());
|
||||
QVERIFY2(desk.seen.value(0).endsWith("|busy"), qPrintable(desk.seen.value(0)));
|
||||
QVERIFY(!busyWhileWriting);
|
||||
QCOMPARE(session.projectPath().value(), copy);
|
||||
}
|
||||
|
||||
void ProjectSaveWhileEditingTests::aFailedWriteLeavesTheEditUnsaved()
|
||||
{
|
||||
QTemporaryDir root;
|
||||
const QString path = savedProject(root);
|
||||
EditorSession session;
|
||||
const auto controller = opened(session, path);
|
||||
const QString locked = root.filePath(QStringLiteral("Locked"));
|
||||
QVERIFY(QDir().mkpath(locked));
|
||||
QVERIFY(QFile::setPermissions(locked, QFileDevice::ReadOwner | QFileDevice::ExeOwner));
|
||||
session.renameLayer(session.document().value().layers[0].id, QStringLiteral("Unsaved"));
|
||||
DialogDesk desk;
|
||||
desk.replies = {locked + QStringLiteral("/Copy.comp"), "OK"};
|
||||
QVERIFY(!answered([&](auto done) { controller->save(true, done); }));
|
||||
QVERIFY(session.isModified());
|
||||
QCOMPARE(session.projectPath().value(), path);
|
||||
// Nothing waits on a write that failed.
|
||||
QVERIFY(answered([&](auto done) { controller->save(false, done); }));
|
||||
QVERIFY(!session.isModified());
|
||||
QFile::setPermissions(locked, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner);
|
||||
}
|
||||
|
||||
void ProjectSaveWhileEditingTests::theLastSignalShowsTheSavedState()
|
||||
{
|
||||
QTemporaryDir root;
|
||||
const QString path = savedProject(root);
|
||||
EditorSession session;
|
||||
const auto controller = opened(session, path);
|
||||
session.renameLayer(session.document().value().layers[0].id, QStringLiteral("Saved"));
|
||||
std::optional<bool> modifiedAtLastSignal;
|
||||
QObject::connect(&session, &EditorSession::changed, [&] { modifiedAtLastSignal = session.isModified(); });
|
||||
QVERIFY(answered([&](auto done) { controller->save(false, done); }));
|
||||
QCOMPARE(modifiedAtLastSignal, std::optional<bool>(false));
|
||||
}
|
||||
|
||||
QTEST_MAIN(ProjectSaveWhileEditingTests)
|
||||
#include "ProjectSaveWhileEditingTests.moc"
|
||||
@@ -0,0 +1,98 @@
|
||||
#pragma once
|
||||
#include "ExternalChangeFixtures.h"
|
||||
#include <QDialog>
|
||||
#include <atomic>
|
||||
#include <dirent.h>
|
||||
#include <dlfcn.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <unistd.h>
|
||||
|
||||
// Shared by the save tests: libc calls held and counted.
|
||||
namespace {
|
||||
// Each sync is slow, so overlapping writes show.
|
||||
std::atomic<int> syncing = 0;
|
||||
std::atomic<int> mostAtOnce = 0;
|
||||
std::atomic<int> slowness = 0;
|
||||
// While set, a package swap waits: a write held mid-save.
|
||||
std::atomic<bool> holdSwap = false;
|
||||
std::atomic<int> swaps = 0;
|
||||
// While it names a folder, listing that folder waits.
|
||||
QByteArray heldListing;
|
||||
std::atomic<bool> holdListing = false;
|
||||
std::atomic<int> listingsHeld = 0;
|
||||
// Loads the store finished, counted from its log.
|
||||
std::atomic<int> loads = 0;
|
||||
QtMessageHandler forward = nullptr;
|
||||
|
||||
void countLoads(QtMsgType type, const QMessageLogContext &context, const QString &message)
|
||||
{
|
||||
if (message.startsWith(QStringLiteral("loaded ")))
|
||||
loads += 1;
|
||||
forward(type, context, message);
|
||||
}
|
||||
|
||||
// Every hold let go and every count cleared.
|
||||
void resetHooks()
|
||||
{
|
||||
mostAtOnce = 0;
|
||||
slowness = 20'000;
|
||||
holdSwap = false;
|
||||
swaps = 0;
|
||||
holdListing = false;
|
||||
listingsHeld = 0;
|
||||
loads = 0;
|
||||
if (!forward)
|
||||
forward = qInstallMessageHandler(countLoads);
|
||||
}
|
||||
|
||||
bool lists(DIR *directory, const QByteArray &folder)
|
||||
{
|
||||
char path[4096];
|
||||
const QByteArray link = "/proc/self/fd/" + QByteArray::number(dirfd(directory));
|
||||
const ssize_t size = readlink(link.constData(), path, sizeof path - 1);
|
||||
return size > 0 && QByteArray(path, int(size)) == folder;
|
||||
}
|
||||
|
||||
template <typename Entry> Entry *held(DIR *directory, Entry *entry)
|
||||
{
|
||||
if (!entry && holdListing && lists(directory, heldListing)) {
|
||||
listingsHeld += 1;
|
||||
while (holdListing)
|
||||
usleep(1000);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
// These take libc's place in the test executable.
|
||||
extern "C" struct dirent *readdir(DIR *directory)
|
||||
{
|
||||
static const auto real = reinterpret_cast<struct dirent *(*)(DIR *)>(dlsym(RTLD_NEXT, "readdir"));
|
||||
return held(directory, real(directory));
|
||||
}
|
||||
|
||||
extern "C" struct dirent64 *readdir64(DIR *directory)
|
||||
{
|
||||
static const auto real = reinterpret_cast<struct dirent64 *(*)(DIR *)>(dlsym(RTLD_NEXT, "readdir64"));
|
||||
return held(directory, real(directory));
|
||||
}
|
||||
|
||||
extern "C" int renameat2(int oldDirectory, const char *from, int newDirectory, const char *to, unsigned int flags)
|
||||
{
|
||||
while (holdSwap)
|
||||
usleep(1000);
|
||||
swaps += 1;
|
||||
return int(syscall(SYS_renameat2, oldDirectory, from, newDirectory, to, flags));
|
||||
}
|
||||
|
||||
extern "C" int fsync(int descriptor)
|
||||
{
|
||||
const int now = ++syncing;
|
||||
int seen = mostAtOnce;
|
||||
while (now > seen && !mostAtOnce.compare_exchange_weak(seen, now)) {
|
||||
}
|
||||
usleep(useconds_t(slowness.load()));
|
||||
const int result = int(syscall(SYS_fsync, descriptor));
|
||||
--syncing;
|
||||
return result;
|
||||
}
|
||||
@@ -154,6 +154,9 @@ compositor_test(OmarchyThemeTests)
|
||||
compositor_test(ProjectControllerTests)
|
||||
compositor_test(ExternalChangeTests)
|
||||
compositor_test(ExternalChangeGateTests)
|
||||
compositor_test(ProjectSaveWhileEditingTests)
|
||||
compositor_test(ProjectSaveQueueTests)
|
||||
compositor_test(ProjectSaveOrderTests)
|
||||
compositor_test(ProjectWatcherTests)
|
||||
compositor_test(ProjectDigestRaceTests)
|
||||
compositor_test(ProjectCopyTests)
|
||||
|
||||
Reference in new issue
Block a user