The decision surface — a refused open becomes a live repair, in place
Phase 3 of the decision surface, completing the card (01 ▸ Malformed input, settled 2026-07-31). An attended open's fail-fast walk transforms the loading window's content into one aggregated surface — never a sheet, never a chain: defects grouped by class, each class stated once with its files listed (Reveal in Finder + Open in Editor per row), a class-level default preselected, per-item override behind a disclosure. Only honest choices: YAML and malformed-schema get Editor + Re-check (Skip below the root); newer-than-app gets Skip alone and blocks the board at the root; the two root repairs — minted index, schema: 1 stamp — are defaults. Repair and Open applies fixes in one store-less write bracket and re-walks: clean proceeds, remainder re-aggregates into the same surface. Cancel and ⌘W retire to welcome's row; restored opens never see the surface at all (OpenOrigin rides the PendingOpen carrier). Skips are per-open consent that rides the session — the store retains the skip set and every reload passes it — and the opened board posts a warning-tone notice naming what was left out, each item's Reveal riding the banner strip's new reveal control. On Pro boards the repair bracket binds its own EchoLedger, heal-marks everything, and the store adopts it before the committer starts, so repairs land as one separate commit authored Lanework Integrity — pinned end to end. Also fixed en route: a retired loading window left its close interception installed and returned false from windowShouldClose forever, blocking quit. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
@@ -130,10 +130,45 @@ public struct LossBanner: Identifiable, Sendable, Equatable {
|
||||
/// When the loss happened — the sort key for "newest first within a class".
|
||||
public let occurredAt: Date
|
||||
|
||||
public init(id: UUID = UUID(), message: String, occurredAt: Date = Date()) {
|
||||
/// **What this row can show the user in Finder**, empty for every loss row that has nothing to
|
||||
/// point at — which is all of them but one.
|
||||
///
|
||||
/// It exists for the skip notice (01-storage-format.md § Malformed input, ruled 2026-07-31: "the
|
||||
/// opened board carries a warning-tone notice naming the skipped items, **each with Reveal in
|
||||
/// Finder**"). The affordance is per *item* while the row is one line, so the targets ride the
|
||||
/// row's data and the strip renders one control over them (`BannerRowControl.reveal`) — a button
|
||||
/// for a sole item, a menu for several. Carrying them here rather than in a row case of their own
|
||||
/// keeps the skip notice in the loss class the ruling puts it in.
|
||||
public let reveals: [RevealTarget]
|
||||
|
||||
public init(
|
||||
id: UUID = UUID(),
|
||||
message: String,
|
||||
occurredAt: Date = Date(),
|
||||
reveals: [RevealTarget] = []
|
||||
) {
|
||||
self.id = id
|
||||
self.message = message
|
||||
self.occurredAt = occurredAt
|
||||
self.reveals = reveals
|
||||
}
|
||||
}
|
||||
|
||||
/// One file a banner row can reveal in Finder.
|
||||
///
|
||||
/// `path` is what the user reads — the board-root-relative spelling `BoardLoadError.path` carries and
|
||||
/// the decision surface's row already showed them — and `url` is what Finder selects. The two are
|
||||
/// carried together rather than derived from each other because only the producer holds the board
|
||||
/// root, and a row that rebuilt a URL from a string would be a second answer to where the board is.
|
||||
public struct RevealTarget: Identifiable, Sendable, Equatable {
|
||||
public let path: String
|
||||
public let url: URL
|
||||
|
||||
public var id: String { path }
|
||||
|
||||
public init(path: String, url: URL) {
|
||||
self.path = path
|
||||
self.url = url
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,7 +362,7 @@ public enum BannerRow: Identifiable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// **This row's buttons, in the order Tab visits them** — Cancel, then Dismiss.
|
||||
/// **This row's buttons, in the order Tab visits them** — Cancel, then Reveal, then Dismiss.
|
||||
///
|
||||
/// It exists because 10-accessibility.md ▸ Full Keyboard Access rules the banner's buttons in by
|
||||
/// name (2026-07-29): "'Every control' is literal and includes banner-row buttons — a Dismiss or
|
||||
@@ -338,14 +373,18 @@ public enum BannerRow: Identifiable, Sendable {
|
||||
/// posture the rest of this type already takes ("the per-kind affordances hang off the row's
|
||||
/// data, not off separate views").
|
||||
///
|
||||
/// No row has both today: the two conditions are disjoint by construction (only an in-progress
|
||||
/// row cancels, and an in-progress row is never dismissable). The order is stated anyway, since
|
||||
/// it is the Tab order the moment one does.
|
||||
/// Cancel and Dismiss are disjoint by construction (only an in-progress row cancels, and an
|
||||
/// in-progress row is never dismissable), so the pair that actually co-occurs is **Reveal then
|
||||
/// Dismiss** — the skip notice's shape. Reveal comes first because it is the row's *content*
|
||||
/// affordance and Dismiss is its lifecycle one: the same reason Cancel precedes Dismiss.
|
||||
public var controls: [BannerRowControl] {
|
||||
var controls: [BannerRowControl] = []
|
||||
if case let .inProgress(operation) = self, let cancel = operation.cancel {
|
||||
controls.append(.cancel(cancel))
|
||||
}
|
||||
if case let .loss(loss) = self, !loss.reveals.isEmpty {
|
||||
controls.append(.reveal(loss.reveals))
|
||||
}
|
||||
if let dismissID {
|
||||
controls.append(.dismiss(dismissID))
|
||||
}
|
||||
@@ -370,10 +409,26 @@ public enum BannerRowControl: Identifiable, Sendable {
|
||||
/// Clear this row, by the id `BannerCenter.dismiss(_:)` takes.
|
||||
case dismiss(UUID)
|
||||
|
||||
/// **Show the files this row is about in Finder** — the skip notice's per-item affordance
|
||||
/// (01-storage-format.md § Malformed input: "each with Reveal in Finder").
|
||||
///
|
||||
/// **One control over N targets, not N controls**, and the reason is the strip's own shape: a
|
||||
/// banner row is one line, three collapsible rows are all the strip shows, and a notice that grew
|
||||
/// a button per skipped item would push the rows below it behind "+N more" on the very board that
|
||||
/// just told the user something went wrong. So the row stays one row, the control stays one Tab
|
||||
/// stop (10-accessibility.md ▸ Full Keyboard Access), and the plurality lives *inside* it — the
|
||||
/// strip renders a plain button for a sole target and a menu naming each path for two or more.
|
||||
///
|
||||
/// Never empty: `BannerRow.controls` only produces it where there is something to reveal.
|
||||
case reveal([RevealTarget])
|
||||
|
||||
public var label: String {
|
||||
switch self {
|
||||
case .cancel: "Cancel"
|
||||
case .dismiss: "Dismiss"
|
||||
// One label for both renderings — it is the button's title *and* the menu's, and it is what
|
||||
// 01 calls the affordance by name.
|
||||
case .reveal: "Reveal in Finder"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,8 +533,11 @@ public final class BannerCenter {
|
||||
|
||||
/// Posts a loss row — content that didn't arrive though nothing failed (settled 2026-07-28, see
|
||||
/// `LossBanner`). Newest first, like the one-shots it shares a lifecycle with.
|
||||
public func postLoss(_ message: String) {
|
||||
losses.insert(LossBanner(message: message), at: 0)
|
||||
///
|
||||
/// - Parameter reveals: the files this row can show in Finder, empty for every producer but the
|
||||
/// skip notice (`LossBanner.reveals`).
|
||||
public func postLoss(_ message: String, reveals: [RevealTarget] = []) {
|
||||
losses.insert(LossBanner(message: message, reveals: reveals), at: 0)
|
||||
}
|
||||
|
||||
/// Posts a passive notice — m6's remote-change signpost and whatever joins it. Newest first,
|
||||
@@ -655,6 +713,28 @@ public final class BannerCenter {
|
||||
postLoss(message)
|
||||
}
|
||||
|
||||
/// **The skip notice** (01-storage-format.md § Malformed input, ruled 2026-07-31): the decision
|
||||
/// surface offered Skip on a defect the app has no honest repair for, the user consented, the
|
||||
/// board opened without that item — "the file stays on disk untouched, tolerated-invisible like
|
||||
/// strays" — and this is the row that says so.
|
||||
///
|
||||
/// > a skipped item loads the board without it … and the opened board carries a warning-tone
|
||||
/// > notice naming the skipped items, each with Reveal in Finder. Skips are per-open decisions,
|
||||
/// > never persisted: the next open of a still-broken board presents the surface again — the
|
||||
/// > notice is the honest residue of this open, not a stored preference.
|
||||
///
|
||||
/// **A loss row, and the ruling names the tone**: the board on screen is not the whole board, and
|
||||
/// that is exactly "content that didn't arrive though nothing failed". It must not evaporate
|
||||
/// unread (the class's untimed lifecycle) and it must not rank as an error, because nothing
|
||||
/// failed — the user chose this.
|
||||
///
|
||||
/// An open that skipped nothing posts nothing: a notice about no skips is not news, and it is
|
||||
/// what every ordinary open passes here.
|
||||
public func postSkippedOnOpen(_ items: [RevealTarget]) {
|
||||
guard let message = Self.skippedOnOpenMessage(for: items) else { return }
|
||||
postLoss(message, reveals: items)
|
||||
}
|
||||
|
||||
/// Posts the skipped-folders loss row for a Finder drop that imported its files but refused its
|
||||
/// folders (04-interactions.md ▸ Selection, drag & drop, "Folders are refused at hover"): "a
|
||||
/// mixed drag proposes for its files only, and the drop imports the files while a one-shot
|
||||
@@ -1039,6 +1119,18 @@ public final class BannerCenter {
|
||||
// this one *does* have a name users know from git, and "the ignore list" would be the
|
||||
// app inventing a word for something already called something.
|
||||
"Couldn't write this board's .gitignore"
|
||||
case .mintBoardIndex:
|
||||
// **Not "couldn't create the board"** — the board is on screen behind the surface, with
|
||||
// its lanes and its cards; what could not be written is the one file that says the
|
||||
// folder is a board. It names `index.md` rather than a role because the decision surface
|
||||
// the user is looking at has just named that file itself, twice: in the class's own
|
||||
// sentence and on the row's Reveal.
|
||||
"Couldn't create this board's index.md"
|
||||
case .stampSchema:
|
||||
// The repair in the user's own words — the surface's choice reads "Stamp schema: 1", so
|
||||
// its failure says the same thing negated. It names no file for `.mintBoardIndex`'s
|
||||
// reason inverted: the file is right there and the surface just showed its path.
|
||||
"Couldn't stamp this board's schema"
|
||||
case let .displaceClaimedName(name):
|
||||
// **The name, quoted, and what the app wanted with it** — the failure's mirror of the
|
||||
// success row ("Renamed '.trash' to '.trash 2' — Lanework needs that name"). It names
|
||||
@@ -1195,6 +1287,30 @@ public final class BannerCenter {
|
||||
"Folders can't be attached — \(count) skipped"
|
||||
}
|
||||
|
||||
/// The skip notice's line, in the relocation family's voice — the act first, the subject after an
|
||||
/// em dash, plurals folded, a sole item named.
|
||||
///
|
||||
/// - **One**: "Opened without 'todo/index.md' — you chose to skip it".
|
||||
/// - **Several**: "Opened without 3 items — you chose to skip them".
|
||||
///
|
||||
/// **The plural fold is safe here in a way it is not elsewhere**, and that is the whole reason
|
||||
/// the count is allowed to stand in for the names: the row carries a Reveal target per item
|
||||
/// (`LossBanner.reveals`), so "which ones" is one click away rather than lost — which is what
|
||||
/// 01's "each with Reveal in Finder" buys. The sole case still names its path, because it fits
|
||||
/// and because a one-item row that said "1 item" would be the app declining to say what it knows.
|
||||
///
|
||||
/// **The tail names the cause**, the migration notice's rule: without it the sentence would read
|
||||
/// as something that happened *to* the user, when it is the choice they just made on the surface.
|
||||
///
|
||||
/// `nil` when nothing was skipped — the ordinary open, and not news.
|
||||
public nonisolated static func skippedOnOpenMessage(for items: [RevealTarget]) -> String? {
|
||||
guard let only = items.first else { return nil }
|
||||
guard items.count == 1 else {
|
||||
return "Opened without \(items.count) items — you chose to skip them"
|
||||
}
|
||||
return "Opened without '\(only.path)' — you chose to skip it"
|
||||
}
|
||||
|
||||
/// The loose-file relocation's line — 01-storage-format.md's own example sentence, "Moved
|
||||
/// 'notes.txt' into attachments — 'Fix login'", generalized over the two axes it varies on.
|
||||
///
|
||||
|
||||
@@ -244,6 +244,25 @@ public final class BoardStore: HealHost {
|
||||
/// describe the tree currently on screen.
|
||||
public private(set) var loadWarnings: [LoadWarning]
|
||||
|
||||
/// **The skips this open consented to** — the decision surface's Skip set, riding the *session*
|
||||
/// (01-storage-format.md § Malformed input, ruled 2026-07-31; the posture settled here rather
|
||||
/// than left to each reload).
|
||||
///
|
||||
/// The ruling makes skips **per-open decisions, never persisted** — "the next open of a
|
||||
/// still-broken board presents the surface again" — and this is what "per open" means once the
|
||||
/// board is on screen: every reload of this session passes the same set, so the consent the user
|
||||
/// gave when the window opened holds for as long as that window does. The alternative — a reload
|
||||
/// that dropped the set — would blank the board on the first foreign filesystem event, because
|
||||
/// the defect the user tolerated is still on disk and would fail the walk again.
|
||||
///
|
||||
/// Nothing writes it and nothing persists it: it arrives at `init` from the open that composed
|
||||
/// it, and dies with the store, which is the ruling's own "not a stored preference".
|
||||
///
|
||||
/// Empty on every ordinary board — no surface, no skips — which is what makes the reload path
|
||||
/// below byte-identical to what it was for every board that opens cleanly.
|
||||
@ObservationIgnored
|
||||
public let skippedPaths: Set<String>
|
||||
|
||||
/// **The pending work the load that produced `snapshot` found** — the typed defect stream
|
||||
/// (`IntegrityRules.Defect`, settled 2026-07-29). Replaced with the snapshot, like
|
||||
/// `loadWarnings`, so it always describes the tree currently on screen.
|
||||
@@ -602,9 +621,9 @@ public final class BoardStore: HealHost {
|
||||
/// with a reload behind it rather than a write into a board nothing is watching yet. A store
|
||||
/// built directly (a test, a storeless consumer) heals when it is asked to, and on every reload
|
||||
/// thereafter.
|
||||
public convenience init(rootURL: URL) throws(BoardLoadFailure) {
|
||||
let result = try BoardLoader.load(boardRoot: rootURL)
|
||||
self.init(rootURL: rootURL, loaded: result)
|
||||
public convenience init(rootURL: URL, skipping: Set<String> = []) throws(BoardLoadFailure) {
|
||||
let result = try BoardLoader.load(boardRoot: rootURL, skipping: skipping)
|
||||
self.init(rootURL: rootURL, loaded: result, skipping: skipping)
|
||||
}
|
||||
|
||||
/// The same board, from a walk that already happened somewhere else.
|
||||
@@ -618,11 +637,16 @@ public final class BoardStore: HealHost {
|
||||
/// `rootURL` is passed rather than read off `result.model` for the reason the property's own doc
|
||||
/// comment gives — the store's root follows an absorbed rename ahead of the snapshot that will
|
||||
/// carry it.
|
||||
public init(rootURL: URL, loaded result: LoadResult) {
|
||||
///
|
||||
/// - Parameter skipping: the skip set the walk was run with, retained for this session's reloads
|
||||
/// (`skippedPaths`). Defaulted to none, which is every board that opened without a decision
|
||||
/// surface.
|
||||
public init(rootURL: URL, loaded result: LoadResult, skipping: Set<String> = []) {
|
||||
self.rootURL = rootURL
|
||||
self.snapshot = result.model
|
||||
self.loadWarnings = result.warnings
|
||||
self.defects = result.defects
|
||||
self.skippedPaths = skipping
|
||||
self.reloadFailure = nil
|
||||
self.readOnlyLock = nil
|
||||
self.transient = TransientBoardState()
|
||||
@@ -693,6 +717,9 @@ public final class BoardStore: HealHost {
|
||||
// `Sendable` value that touches libgit2 only if this walk finds a duplicate identity to
|
||||
// break a tie for. `nil` everywhere the app manages no git.
|
||||
let historyRanker = makeIdentityHistoryRanker?()
|
||||
// **This session's consented skips, on every walk it runs** (`skippedPaths`): the open's
|
||||
// decision stands for the session, so a reload sees the board the user chose to open.
|
||||
let skipping = skippedPaths
|
||||
reloadInFlight = true
|
||||
Self.logger.debug("reload \(generation, privacy: .public) started (\(origin.rawValue, privacy: .public))")
|
||||
|
||||
@@ -701,7 +728,8 @@ public final class BoardStore: HealHost {
|
||||
// and the loader's typed failure is lost on the way into `Result`.
|
||||
let outcome: Result<LoadResult, BoardLoadFailure>
|
||||
do throws(BoardLoadFailure) {
|
||||
outcome = .success(try BoardLoader.load(boardRoot: root, historyRanker: historyRanker))
|
||||
outcome = .success(try BoardLoader.load(
|
||||
boardRoot: root, skipping: skipping, historyRanker: historyRanker))
|
||||
} catch {
|
||||
outcome = .failure(error)
|
||||
}
|
||||
|
||||
@@ -197,21 +197,37 @@ public final class BoardStoreRegistry {
|
||||
/// returns `nil` before constructing anything, so there is no store, no watcher, no entry and no
|
||||
/// reference — and the caller's security-scoped access is its own to release.
|
||||
///
|
||||
/// - Parameter skipping: **the decision surface's consented skips** (01-storage-format.md
|
||||
/// § Malformed input, ruled 2026-07-31), passed to the walk and then *retained by the store*
|
||||
/// so every reload of the resulting session runs with the same set (`BoardStore.skippedPaths`).
|
||||
/// Empty — the default — is every board that opens without a surface.
|
||||
///
|
||||
/// - Returns: the board's store, or `nil` if this acquire was cancelled before its walk landed.
|
||||
/// `nil` is not a failure: nothing went wrong and nothing was opened.
|
||||
public func acquireOffMain(_ rootURL: URL) async throws(BoardLoadFailure) -> BoardStore? {
|
||||
public func acquireOffMain(
|
||||
_ rootURL: URL,
|
||||
skipping: Set<String> = []
|
||||
) async throws(BoardLoadFailure) -> BoardStore? {
|
||||
if let store = referenceExistingBoard(at: rootURL) { return store }
|
||||
|
||||
// `nil` for a root that does not exist or whose volume will not answer — there is nothing to
|
||||
// coalesce on, so such an open walks alone and the loader produces the honest error for it.
|
||||
let identity = FileIdentity(of: rootURL)
|
||||
//
|
||||
// **A skip-carrying acquire also walks alone**, deliberately: the single flight exists so
|
||||
// concurrent opens of one board share a walk, and two walks are only shareable when they
|
||||
// would produce the same answer. A skip set changes what the walk *finds*, so joining one
|
||||
// would hand a window a board someone else's consent composed — and a Repair-and-Open's
|
||||
// re-walk could be answered by the very walk that failed. The condition is exactly "an
|
||||
// ordinary open", which is every open the coalescing was built for (restoration, a Finder
|
||||
// open racing it, a card window arriving first) and none of the surface's.
|
||||
let identity = skipping.isEmpty ? FileIdentity(of: rootURL) : nil
|
||||
|
||||
let walk: Task<Result<LoadResult, BoardLoadFailure>, Never>
|
||||
if let identity, let joined = walksInFlight[identity] {
|
||||
Self.logger.debug("acquire: joining the walk already running for this board")
|
||||
walk = joined
|
||||
} else {
|
||||
walk = Self.walk(rootURL)
|
||||
walk = Self.walk(rootURL, skipping: skipping)
|
||||
if let identity { walksInFlight[identity] = walk }
|
||||
}
|
||||
|
||||
@@ -238,7 +254,8 @@ public final class BoardStoreRegistry {
|
||||
case let .failure(failure):
|
||||
throw failure
|
||||
case let .success(result):
|
||||
return try adopt(BoardStore(rootURL: rootURL, loaded: result), rootURL: rootURL)
|
||||
return try adopt(
|
||||
BoardStore(rootURL: rootURL, loaded: result, skipping: skipping), rootURL: rootURL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,10 +263,13 @@ public final class BoardStoreRegistry {
|
||||
/// `BoardStore.startReload`'s reason exactly: a task created inside a `@MainActor` method
|
||||
/// inherits that isolation and would run the walk on the main actor, which is the whole thing
|
||||
/// this is avoiding.
|
||||
private static func walk(_ rootURL: URL) -> Task<Result<LoadResult, BoardLoadFailure>, Never> {
|
||||
private static func walk(
|
||||
_ rootURL: URL,
|
||||
skipping: Set<String>
|
||||
) -> Task<Result<LoadResult, BoardLoadFailure>, Never> {
|
||||
Task.detached(priority: .userInitiated) {
|
||||
do throws(BoardLoadFailure) {
|
||||
return .success(try BoardLoader.load(boardRoot: rootURL))
|
||||
return .success(try BoardLoader.load(boardRoot: rootURL, skipping: skipping))
|
||||
} catch {
|
||||
return .failure(error)
|
||||
}
|
||||
|
||||
@@ -246,6 +246,54 @@ public final class EchoLedger: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// **Marks everything this ledger holds as a heal** — the whole-ledger form of `markHeal(at:)`,
|
||||
/// for a ledger whose *every* receipt is a heal by construction.
|
||||
///
|
||||
/// Its one caller is the decision surface's repair bracket (01-storage-format.md § Malformed
|
||||
/// input: "On Pro boards the repairs drop heal-marked receipts and commit separately as one
|
||||
/// repair commit"). Repairs run **before** the board has a store — there is no `BoardStore` yet,
|
||||
/// so no `performWrite` to bind — so the repair binds a ledger of its own for the duration of the
|
||||
/// bracket. Everything that lands in it is a repair, which is exactly the condition that makes a
|
||||
/// blanket mark honest here and would make it a lie on a session ledger.
|
||||
///
|
||||
/// Path-by-path marking would need the repair runner to enumerate the files each `BoardWriter`
|
||||
/// call happened to touch — `createBoard` writes `index.md` *and* seeds `.gitignore` — which is
|
||||
/// bookkeeping the Writer exists to keep call sites out of.
|
||||
public func markAllAsHeal() {
|
||||
receipts.withLock { store in
|
||||
for path in store.keys {
|
||||
store[path]?.isHeal = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// **Takes over another ledger's receipts, attributes and all** — the repair bracket's ledger
|
||||
/// handed to the board's own once the board finally has one.
|
||||
///
|
||||
/// The decision surface repairs a board that has no store, then re-walks it; the walk succeeds,
|
||||
/// the store is built, and only *then* does a session ledger exist. Without this the repair's
|
||||
/// receipts would die with the temporary ledger and Pro's committer would author the app's own
|
||||
/// repair as `Lanework External` — the one misattribution the whole mechanism exists to prevent.
|
||||
///
|
||||
/// **Safe because a receipt vouches against disk, not against a clock** (`Receipt.isSatisfied`):
|
||||
/// the repaired files are on disk exactly as the repair left them, and the re-walk that just
|
||||
/// succeeded read those very bytes. An adopted receipt is therefore satisfiable the moment it
|
||||
/// arrives, which is the same standing a receipt dropped inside a write bracket has.
|
||||
///
|
||||
/// Plain overwrite, the supersession rule: the adopting ledger is brand new in the only case that
|
||||
/// calls this, and a receipt it already held for the same path would be the newer of the two —
|
||||
/// which is the one case the ledger's own `recordWrite` also resolves by keeping what it has been
|
||||
/// told last. Nothing is removed from `other`; it is discarded whole by its caller.
|
||||
public func adopt(_ other: EchoLedger) {
|
||||
let entries = other.receipts.withLock { $0 }
|
||||
guard !entries.isEmpty else { return }
|
||||
receipts.withLock { store in
|
||||
for (path, entry) in entries {
|
||||
store[path] = entry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func forget(_ store: inout [String: Entry], under path: String) {
|
||||
let prefix = path + "/"
|
||||
for key in Array(store.keys) where key.hasPrefix(prefix) {
|
||||
|
||||
Reference in New Issue
Block a user