import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { buildChipMap } from '../data/statusChips';
import { pulledCleanly } from '../data/pullResults';
import { computeServerDeletions } from '../data/serverDeletions';
import {
  getTransferState,
  setTransferState,
  subscribeTransferState,
} from '../data/transferStore';
import { buildCommandLine, dispatchFileCommand } from '../data/fileCommand';
import {
  classifyAddTargets,
  classifyDeleteTargets,
  planRevert,
  planSubmit,
  planPull,
  seedPullingPaths,
  submitDialogs,
} from '../data/fileVerbs';
import { useConnection } from '../context/ConnectionContext';
import { useConsoleLog } from '../context/ConsoleContext';
import { useServerData } from '../context/ServerDataContext';
import { useSettings } from '../context/SettingsContext';
import { getPin } from '../data/serverPins';
import {
  computeStatus,
  deleteWorkspaceFiles,
  isTauri,
  loadClientState,
  pullWorkspaceFiles,
  saveClientState,
  setWorkspaceWritable,
} from '../platform';

/**
 * The client half of the revision-control engine: file statuses, the pending
 * changelist (marked-for-add / marked-for-delete), toolbar gating, and the
 * checkout / revert / add / delete / pull actions RepoPanel's toolbar fires.
 *
 * Everything is keyed by depot-relative path ('Assets/Player.cs'), the shared
 * currency between the depot tree, the workspace folder, and the lock table.
 */

const CHANGELIST_KEY = 'cbm-changelist-v1';
// Depot paths seen in the workspace on the last scan, per connection, mapped to
// the depot HEAD hash the local copy is based on (the version pulled / synced /
// checked out to disk). Lets us tell "file was deleted outside the app"
// (auto-mark for delete) apart from "file was never synced here", tell whether a
// server-removed file was ever pulled here (base hash present ⇒ came from the
// server), and tell whether an edit is stale (base hash != current head).
const SYNCED_KEY = 'cbm-synced-v1';
// Paths a user has already been shown the "files deleted on the server"
// popup for, per (server, user, workspace folder) — same scope as the synced
// manifest. Persisted (not just an in-session ref) so the one-shot behavior
// survives reconnects, and so it can never leak between two different users'
// identities sharing one running app instance.
const ANSWERED_DELETIONS_KEY = 'cbm-answered-deletions-v1';
// Same shape/reasoning as ANSWERED_DELETIONS_KEY, for the checkout-conflict
// (orange) and stale-revision (red) one-shot warnings below. These used to be
// plain in-memory refs, which meant switching to another top-level workspace
// page (Organization, Time Tracking — each with its own panel layout, see
// layoutEngine.js) and back unmounted/remounted RepoPanel's useFileControl
// instance, wiping the ref and re-showing a warning the user had already
// dismissed for a condition that hadn't changed. Persisting them the same
// way answeredDeletions already is fixes that.
const WARNED_CHECKOUTS_KEY = 'cbm-warned-checkouts-v1';
const WARNED_STALE_KEY = 'cbm-warned-stale-v1';
const PULSE_MS = 1400; // how long pulled changes flash in the tree

const PULL_HELP = {
  icon: 'assets/pull.svg',
  color: 'grey',
  title: 'How pulling works',
  text:
    'Pull Latest downloads the newest file versions from the server into your ' +
    'workspace, scoped to your selection: select a folder to pull it and ' +
    'everything inside it, or select the root folder to pull the entire ' +
    'repository. Anything that changes on disk pulses in the file tree. Files ' +
    'you have checked out are never overwritten. Select something and pull again.',
};

function loadChangelist(key) {
  try {
    const all = JSON.parse(localStorage.getItem(CHANGELIST_KEY) ?? '{}');
    const mine = all[key];
    return {
      add: new Set(mine?.add ?? []),
      del: new Set(mine?.del ?? []),
      delFolders: new Set(mine?.delFolders ?? []),
    };
  } catch {
    return { add: new Set(), del: new Set(), delFolders: new Set() };
  }
}

function saveChangelist(key, changelist) {
  try {
    const all = JSON.parse(localStorage.getItem(CHANGELIST_KEY) ?? '{}');
    all[key] = {
      add: [...changelist.add],
      del: [...changelist.del],
      delFolders: [...changelist.delFolders],
    };
    localStorage.setItem(CHANGELIST_KEY, JSON.stringify(all));
  } catch {
    /* ignore (quota) */
  }
}

/**
 * Whether every input the status engine consumes has actually hydrated, so a
 * computed status map can be trusted enough to put on screen.
 *
 * The four sources fill independently and asynchronously — changelist and
 * synced manifest from the native store, the local listing from a disk scan,
 * the depot tree from the server — and each has an "empty" value that is
 * indistinguishable from real data: an empty manifest reads as "nothing was
 * ever synced here", an empty changelist as "nothing is marked", a null
 * localListing as "the workspace is empty". Computing on an intermediate
 * combination is what made files flash wrong statuses on open and on every
 * window refocus.
 *
 * `localListing` is only required when a workspace folder is actually set:
 * with no folder there is no scan to wait for, and the Server tab still needs
 * its lock statuses.
 *
 * Pure — no React, no I/O.
 */
export function statusInputsReady({
  manifestReady,
  changelistReady,
  workspacePath,
  localListing,
  depotTree,
}) {
  if (!manifestReady || !changelistReady) return false;
  if (depotTree == null) return false;
  if (workspacePath && localListing == null) return false;
  return true;
}

// The synced manifest is a Map(path -> baseHash|null). A truthy hash means the
// file was pulled from the server and we know the depot version it is based on;
// null means the path was seen on disk but its base is unknown (a local-only
// file, or an entry migrated from the old array format).
function loadSyncedHashes(key) {
  try {
    const all = JSON.parse(localStorage.getItem(SYNCED_KEY) ?? '{}');
    const mine = all[key];
    const map = new Map();
    if (Array.isArray(mine)) {
      // Old format: paths only, base hash unknown.
      for (const path of mine) map.set(path, null);
    } else if (mine && typeof mine === 'object') {
      for (const [path, hash] of Object.entries(mine)) map.set(path, hash ?? null);
    }
    return map;
  } catch {
    return new Map();
  }
}

function saveSyncedHashes(key, map) {
  try {
    const all = JSON.parse(localStorage.getItem(SYNCED_KEY) ?? '{}');
    const obj = {};
    for (const [path, hash] of map) obj[path] = hash ?? null;
    all[key] = obj;
    localStorage.setItem(SYNCED_KEY, JSON.stringify(all));
  } catch {
    /* ignore (quota) */
  }
}

// Native manifest key namespace. The manifest is the only per-file-scaled
// record (path -> 64-char base hash for every synced file), which is why it
// gets native persistence while the changelist / one-shot warning sets stay
// in localStorage (they are tiny).
const NATIVE_SYNCED_PREFIX = 'synced::';

// Per-key save queue. Two overlapping saveClientState calls for the same key
// can interleave on a shared temp file (Task 6 review finding), so every save
// for a given key awaits the previous one. Failures are swallowed for chaining
// so one bad write can't wedge the queue.
const saveQueues = new Map();
function queueClientSave(key, value) {
  const prev = saveQueues.get(key) ?? Promise.resolve();
  const next = prev.then(() => saveClientState(key, value));
  saveQueues.set(key, next.catch(() => {}));
  return next;
}

function manifestToObject(map) {
  const obj = {};
  for (const [path, hash] of map) obj[path] = hash ?? null;
  return obj;
}

function manifestFromJson(text) {
  try {
    const obj = JSON.parse(text);
    const map = new Map();
    if (obj && typeof obj === 'object') {
      for (const [path, hash] of Object.entries(obj)) map.set(path, hash ?? null);
    }
    return map;
  } catch {
    return new Map();
  }
}

async function loadManifest(key) {
  if (isTauri) {
    const native = await loadClientState(NATIVE_SYNCED_PREFIX + key);
    if (typeof native === 'string') return manifestFromJson(native);
    if (native === null) {
      // null = never saved natively since the native store landed. Import the
      // localStorage copy once so existing sync state carries over, then let
      // native persistence own it from here.
      const legacy = loadSyncedHashes(key);
      if (legacy.size) {
        queueClientSave(NATIVE_SYNCED_PREFIX + key, JSON.stringify(manifestToObject(legacy)));
      }
      return legacy;
    }
    // undefined = the native read FAILED (or no bridge). Degrade to the legacy
    // localStorage manifest so a transient native error behaves like the old
    // client rather than an empty manifest — but do NOT migrate/save in this
    // case, or we'd overwrite a possibly-good native file with possibly-stale
    // legacy data.
    return loadSyncedHashes(key);
  }
  return loadSyncedHashes(key);
}

// Returns a promise that resolves once the write lands (or is queued
// synchronously on the localStorage fallback), so a caller that also needs
// to trigger an engine verb touching the SAME native file (see doDelete's
// mixed engine/local-only branch) can await it first instead of racing Rust's
// own read of that file.
function persistManifest(key, map) {
  if (isTauri) {
    return queueClientSave(NATIVE_SYNCED_PREFIX + key, JSON.stringify(manifestToObject(map)));
  }
  saveSyncedHashes(key, map);
  return Promise.resolve();
}

// Native changelist key namespace. Unlike the manifest above, the changelist
// is tiny, but it's still moved to native storage (rather than staying
// localStorage-only) so the shared Cambium dir is a complete picture of
// pending work for a future CLI acting as the same device.
const NATIVE_CHANGELIST_PREFIX = 'changelist::';

/** Pure serializer: exported for the cross-language JSON-contract test (see
 * useFileControl.changelist.test.js) pinning the literal string against
 * core's `Changelist`/`ChangelistFile` serde shape (camelCase, exactly these
 * four fields in this order) — crates/cambium-core/src/config.rs. */
export function changelistToNativeJson(changelist) {
  return JSON.stringify({
    formatVersion: 1,
    add: [...changelist.add],
    del: [...changelist.del],
    delFolders: [...changelist.delFolders],
  });
}

function changelistFromNativeJson(text) {
  try {
    const obj = JSON.parse(text);
    return {
      add: new Set(obj?.add ?? []),
      del: new Set(obj?.del ?? []),
      delFolders: new Set(obj?.delFolders ?? []),
    };
  } catch {
    return { add: new Set(), del: new Set(), delFolders: new Set() };
  }
}

async function loadChangelistAsync(key) {
  if (isTauri) {
    const native = await loadClientState(NATIVE_CHANGELIST_PREFIX + key);
    if (typeof native === 'string') return changelistFromNativeJson(native);
    if (native === null) {
      // null = never saved natively since the native store landed. Import the
      // localStorage copy once so an existing changelist carries over, then
      // let native persistence own it from here — same one-time-migration
      // shape as loadManifest above.
      const legacy = loadChangelist(key);
      if (legacy.add.size || legacy.del.size || legacy.delFolders.size) {
        queueClientSave(NATIVE_CHANGELIST_PREFIX + key, changelistToNativeJson(legacy));
      }
      return legacy;
    }
    // undefined = the native read FAILED (or no bridge). Degrade to the
    // legacy localStorage changelist rather than an empty one, same
    // reasoning as loadManifest — but don't migrate/save in this case.
    return loadChangelist(key);
  }
  return loadChangelist(key);
}

// Returns a promise resolving once the write lands — same reasoning as
// persistManifest above.
function persistChangelistNative(key, changelist) {
  if (isTauri) {
    return queueClientSave(NATIVE_CHANGELIST_PREFIX + key, changelistToNativeJson(changelist));
  }
  saveChangelist(key, changelist);
  return Promise.resolve();
}

function loadAnsweredDeletions(key) {
  try {
    const all = JSON.parse(localStorage.getItem(ANSWERED_DELETIONS_KEY) ?? '{}');
    return new Set(all[key] ?? []);
  } catch {
    return new Set();
  }
}

function saveAnsweredDeletions(key, set) {
  try {
    const all = JSON.parse(localStorage.getItem(ANSWERED_DELETIONS_KEY) ?? '{}');
    all[key] = [...set];
    localStorage.setItem(ANSWERED_DELETIONS_KEY, JSON.stringify(all));
  } catch {
    /* ignore (quota) */
  }
}

// Generic persisted-Set helpers, shared by the checkout-conflict and
// stale-revision one-shot warnings (same shape as the deletions one above).
function loadWarnedSet(storageKey, identityKey) {
  try {
    const all = JSON.parse(localStorage.getItem(storageKey) ?? '{}');
    return new Set(all[identityKey] ?? []);
  } catch {
    return new Set();
  }
}

function saveWarnedSet(storageKey, identityKey, set) {
  try {
    const all = JSON.parse(localStorage.getItem(storageKey) ?? '{}');
    all[identityKey] = [...set];
    localStorage.setItem(storageKey, JSON.stringify(all));
  } catch {
    /* ignore (quota) */
  }
}

/** Split the active tab's selected node ids into depot-relative scopes. */
function selectionScopes(selection, rootId) {
  const scopes = { hasRoot: false, files: [], folders: [] };
  if (!selection?.tree || !rootId) return scopes;

  function walk(node) {
    if (selection.selectedIds.has(node.id)) {
      if (node.id === rootId || node.isRoot) scopes.hasRoot = true;
      else if (node.type === 'folder') scopes.folders.push(node.id.slice(rootId.length));
      else scopes.files.push(node.id.slice(rootId.length));
    }
    node.children?.forEach(walk);
  }
  selection.tree.forEach(walk);
  return scopes;
}

function underScopes(path, scopes) {
  return (
    scopes.hasRoot ||
    scopes.files.includes(path) ||
    scopes.folders.some((folder) => path.startsWith(`${folder}/`))
  );
}

/** Same shape as the old fileStatus.js `flattenLocalFiles`: workspace listing
 * (readWorkspaceTree's children, with hashes) -> Map relPath -> file info.
 * `localFiles` still needs this locally: cambium-core's engine only returns
 * `serverFiles` (see the engine effect below) — the workspace side is read
 * straight off `localListing` by candidates/read-only-policy/external-deletion
 * code that was untouched by this task. */
function flattenLocalListing(children, prefix = '', out = new Map()) {
  for (const node of children ?? []) {
    const rel = prefix ? `${prefix}/${node.name}` : node.name;
    if (node.type === 'file') {
      out.set(rel, { hash: node.hash, writable: node.writable, sizeBytes: node.sizeBytes });
    } else if (node.type === 'folder') {
      flattenLocalListing(node.children, rel, out);
    }
  }
  return out;
}

/** Synchronous flatten of the server tree snapshot for the submit/pull verb code below:
 * `refreshDepotTree()` returns a same-call snapshot that must be flattened immediately,
 * and can't wait a beat for the async engine effect to catch up — everywhere else
 * reads the engine's `serverFiles` instead. */
function flattenServerListing(children, prefix = '', out = new Map()) {
  for (const node of children ?? []) {
    const rel = prefix ? `${prefix}/${node.name}` : node.name;
    if (node.type === 'file') {
      out.set(rel, { hash: node.hash, sizeBytes: node.sizeBytes, headRevision: node.headRevision });
    } else if (node.type === 'folder') {
      flattenServerListing(node.children, rel, out);
    }
  }
  return out;
}

export function useFileControl({ selection, rootId, workspacePath, localListing, reloadWorkspace }) {
  const { api, account, deviceId, activeProjectId, projectName } = useConnection();
  const {
    depotTree,
    revisions,
    locks,
    setLocks,
    refreshDepotTree,
    refreshRevisions,
    refreshLocks,
    getUser,
    ignoreText,
    users,
  } = useServerData();
  const { allowUncheckedEdits, warningsEnabled } = useSettings();
  const { appendLine } = useConsoleLog();

  const [dialog, setDialog] = useState(null); // { type: 'info'|'confirm', ... }
  const [busy, setBusy] = useState(false);
  const [pulse, setPulse] = useState(null); // Set of relPaths currently flashing
  // Live transfer readout ({ kind, path, done, total } | null) plus the
  // active pull's path set (for "not added" suppression). Module-level store,
  // NOT component state: switching top-level pages unmounts this hook while
  // the Rust engine keeps transferring — component state reset to idle on
  // remount, re-arming the toolbar and hiding the progress strip mid-download.
  const { transfer, pullingPaths } = useSyncExternalStore(
    subscribeTransferState,
    getTransferState
  );
  const pulseTimer = useRef(null);

  useEffect(() => () => clearTimeout(pulseTimer.current), []);

  const log = useCallback(
    (ok, text) => appendLine({ type: ok ? 'out' : 'err', text }),
    [appendLine]
  );

  // Changelist + synced manifest are per (project, user, workspace folder).
  // The changelist hydrates asynchronously now (native store, mirroring the
  // manifest below) rather than a synchronous localStorage read, so it goes
  // through the same key/ready pattern: changelistLoadedKey records which
  // identity the loaded changelist belongs to, and changelistReady gates any
  // mutation until it matches the current changelistKey — otherwise a
  // just-swapped identity's stale (or empty, pre-hydration) changelist could
  // be mutated and persisted right over the real one that's still loading.
  const changelistKey = `${activeProjectId ?? ''}::${account?.username ?? ''}::${workspacePath}`;
  const [changelist, setChangelist] = useState(() => ({
    add: new Set(),
    del: new Set(),
    delFolders: new Set(),
  }));
  const [changelistLoadedKey, setChangelistLoadedKey] = useState(null);
  const changelistReady = changelistLoadedKey === changelistKey;

  // Bumped after any successful engine verb (checkout/add/delete/revert):
  // those verbs persist the changelist/manifest straight into the SAME
  // native-store files this hook loads (cambium-core's `changelist::<key>` /
  // `synced::<key>`, see config.rs's `changelist_key` — identical key format
  // to `changelistKey` below), so the in-memory copies here go stale the
  // instant Rust writes them. Both hydration effects below depend on this
  // nonce so a bump re-runs them against the current `changelistKey` without
  // needing an identity change.
  const [rehydrateNonce, setRehydrateNonce] = useState(0);
  const rehydrate = useCallback(() => setRehydrateNonce((n) => n + 1), []);

  // Tracks the last `changelistKey` the two hydration effects below ran for,
  // so a rehydrate-only bump (identity unchanged) can skip the "reset to
  // empty, drop ready" step that a real identity swap needs — re-running
  // that reset on every post-verb rehydrate would flash an empty changelist
  // (and un-ready gate) for the tick the fresh native read takes, right when
  // a just-finished action's own candidates are being recomputed.
  const prevChangelistKeyRef = useRef(null);

  useEffect(() => {
    let cancelled = false;
    const identityChanged = prevChangelistKeyRef.current !== changelistKey;
    prevChangelistKeyRef.current = changelistKey;
    if (identityChanged) {
      setChangelistLoadedKey(null);
      setChangelist({ add: new Set(), del: new Set(), delFolders: new Set() });
    }
    loadChangelistAsync(changelistKey).then((loaded) => {
      if (cancelled) return;
      setChangelist(loaded);
      setChangelistLoadedKey(changelistKey);
    });
    return () => {
      cancelled = true;
    };
  }, [changelistKey, rehydrateNonce]);

  // In-memory mirror of the synced manifest (path → base hash), kept current by
  // the scan effect below so statuses can tell Outdated (clean but behind)
  // apart from Modified (locally edited).
  //
  // baseHashes hydrates asynchronously now (native store — localStorage's ~5MB
  // quota fails silently on big workspaces). manifestKey records which identity
  // the loaded map belongs to; until it matches the current changelistKey the
  // manifest is NOT ground truth, and every consumer that could MUTATE state
  // based on "path missing from manifest" must wait — otherwise a not-yet-loaded
  // manifest reads as "nothing was ever synced here" and auto-marks files for
  // delete (the identity-swap failure class RepoPanel guards against at render).
  const [baseHashes, setBaseHashes] = useState(() => new Map());
  const [manifestKey, setManifestKey] = useState(null);
  const manifestReady = manifestKey === changelistKey;
  const prevManifestKeyRef = useRef(null);

  useEffect(() => {
    let cancelled = false;
    const identityChanged = prevManifestKeyRef.current !== changelistKey;
    prevManifestKeyRef.current = changelistKey;
    if (identityChanged) {
      setManifestKey(null);
      setBaseHashes(new Map());
    }
    loadManifest(changelistKey).then((map) => {
      if (cancelled) return;
      setBaseHashes(map);
      setManifestKey(changelistKey);
    });
    return () => {
      cancelled = true;
    };
  }, [changelistKey, rehydrateNonce]);

  const updateChangelist = useCallback(
    (mutate) => {
      // Sole mutation choke-point: no-op (rather than mutate + persist) until
      // the changelist for THIS identity has hydrated, so a click that lands
      // in the gap between an identity swap and the async load finishing
      // can't overwrite the real (still-loading) changelist with a mutated
      // empty one.
      if (!changelistReady) {
        log(false, 'skipped changelist update: not hydrated yet');
        return Promise.resolve();
      }
      // Returns the underlying persist promise so a caller that also needs to
      // run an engine verb touching the same native file right after (doDelete's
      // mixed engine/local-only branch) can await this write landing first.
      let persisted = Promise.resolve();
      setChangelist((prev) => {
        const next = {
          add: new Set(prev.add),
          del: new Set(prev.del),
          delFolders: new Set(prev.delFolders),
        };
        mutate(next);
        persisted = persistChangelistNative(changelistKey, next);
        return next;
      });
      return persisted;
    },
    [changelistKey, changelistReady, log]
  );

  // A folder counts as marked if it, or any ancestor folder, is in the
  // changelist as a folder delete (marking a folder takes its whole subtree).
  const isFolderMarked = useCallback(
    (rel) =>
      [...changelist.delFolders].some(
        (folder) => rel === folder || rel.startsWith(`${folder}/`)
      ),
    [changelist.delFolders]
  );

  const localFiles = useMemo(() => flattenLocalListing(localListing?.children), [localListing]);

  const me = useMemo(
    () => (account ? { username: account.username, deviceId } : null),
    [account, deviceId]
  );

  const lockedByMe = useCallback(
    (path) => {
      const lock = locks[path];
      return Boolean(
        lock &&
          me &&
          lock.username.toLowerCase() === me.username.toLowerCase() &&
          lock.deviceId === me.deviceId
      );
    },
    [locks, me]
  );

  // Per-file statuses + the flattened depot tree, both computed by
  // cambium-core's engine (the same one `cambium status` uses) rather than
  // client-side JS. Sequence-guarded so a stale response from a slower,
  // superseded call can never clobber a newer one that already landed.
  const [engine, setEngine] = useState({ statuses: [], serverFiles: new Map() });
  const statusSeq = useRef(0);
  // Identity reset: `engine` is otherwise only ever filled by the async
  // compute_status round trip below, so right after a project/workspace
  // switch (changelistKey changes) the PREVIOUS identity's statuses/
  // serverFiles would still be sitting in state — briefly consumed against
  // the new identity's manifest/local data — until the next round trip lands.
  // Clear it immediately and bump statusSeq so any in-flight response for the
  // old identity is discarded when it arrives. Mirrors the
  // prevChangelistKeyRef pattern above.
  const prevEngineKeyRef = useRef(null);
  useEffect(() => {
    if (prevEngineKeyRef.current === changelistKey) return;
    prevEngineKeyRef.current = changelistKey;
    statusSeq.current += 1;
    setEngine({ statuses: [], serverFiles: new Map() });
  }, [changelistKey]);
  useEffect(() => {
    if (!me) return;
    // Never compute statuses from half-hydrated inputs. The four sources fill
    // independently (native store, disk scan, server), and an intermediate
    // combination yields chips that are plainly wrong — an empty manifest reads
    // as "nothing was ever synced here", an empty changelist as "nothing is
    // marked", and a null localListing as "the workspace is empty", so files
    // flashed bogus statuses on open and after every window refocus. Skipping
    // the round trip leaves `engine` holding the last fully-hydrated result, so
    // the tree keeps showing the last known-good statuses instead (or none at
    // all right after an identity switch, where `engine` was just cleared —
    // no information rather than false information). Same readiness flags the
    // three detectors below already gate on.
    if (!statusInputsReady({ manifestReady, changelistReady, workspacePath, localListing, depotTree })) {
      return;
    }
    const seq = ++statusSeq.current;
    const inputs = {
      depotTree: depotTree ?? { children: [] },
      localChildren: localListing?.children ?? [],
      locks,
      changelist: { add: [...changelist.add], del: [...changelist.del], delFolders: [...changelist.delFolders] },
      baseHashes: Object.fromEntries(baseHashes),
      me: { username: me.username, deviceId: me.deviceId },
      ignoreText,
      users,
    };
    computeStatus(inputs).then((resp) => {
      if (!resp || statusSeq.current !== seq) return;
      setEngine({
        statuses: resp.statuses,
        serverFiles: new Map(Object.entries(resp.serverFiles).map(([p, f]) => [p, f])),
      });
    });
  }, [depotTree, localListing, locks, changelist, baseHashes, me, ignoreText, users, manifestReady, changelistReady, workspacePath]);
  const serverFiles = engine.serverFiles;
  const statuses = useMemo(
    () => buildChipMap(engine.statuses, { pullingPaths, serverReady: depotTree != null }),
    [engine.statuses, pullingPaths, depotTree]
  );

  // ---- read-only policy ------------------------------------------------------
  // Synced files are only editable while checked out (unless the
  // allow-unchecked-edits setting is on). Enforced as a diff against the last
  // scan: flip only the files whose read-only bit disagrees with the policy.
  useEffect(() => {
    if (!workspacePath || !localListing) return;
    const lockDown = [];
    const unlock = [];
    for (const [path, info] of localFiles) {
      if (!serverFiles.has(path)) continue; // local-only files are the user's business
      if (typeof info.writable !== 'boolean') continue;
      const shouldWrite = lockedByMe(path) || allowUncheckedEdits;
      if (info.writable && !shouldWrite) lockDown.push(path);
      else if (!info.writable && shouldWrite) unlock.push(path);
    }
    if (lockDown.length) {
      setWorkspaceWritable({ folderPath: workspacePath, paths: lockDown, writable: false });
    }
    if (unlock.length) {
      setWorkspaceWritable({ folderPath: workspacePath, paths: unlock, writable: true });
    }
  }, [workspacePath, localListing, localFiles, serverFiles, lockedByMe, allowUncheckedEdits]);

  // ---- external-deletion detection -------------------------------------------
  // A synced file that vanishes from disk becomes a marked-for-delete entry in
  // the changelist (Perforce-style); a vanished not-yet-added file is simply
  // forgotten. Compared against the paths seen on the previous scan.
  useEffect(() => {
    // Gate on the manifest being hydrated for the CURRENT identity: a
    // pre-hydration empty map reads as "nothing was ever synced here", so this
    // detector would neither find real external deletions nor — worse — could
    // persist that empty map back over the native store, wiping every base
    // hash. Wait until manifestReady before trusting or rewriting it.
    // Also wait for changelistReady: a pre-hydration empty changelist reads
    // as "nothing is marked for delete/add yet", which would both misfire
    // this detector and race the still-loading real changelist.
    if (!manifestReady || !changelistReady) return;
    if (!localListing || serverFiles.size === 0 || !account) return;
    // Never auto-mark for delete while one of our own actions is in flight.
    // A revert/pull/rollback records base hashes and refreshes the depot tree
    // BEFORE the async workspace rescan lands, so for a moment the synced
    // manifest and serverFiles both know about a file the local listing hasn't
    // caught up to yet — which this detector would misread as "deleted outside
    // the app" and mark for delete (it wrongly stuck restored/rolled-back files
    // as marked-for-delete). Real external deletions happen while idle; they're
    // caught on the next scan once the operation finishes and busy clears.
    if (busy) return;
    const known = baseHashes;
    const missing = [...known.keys()].filter((path) => !localFiles.has(path));
    const newDeletes = missing.filter(
      (path) => serverFiles.has(path) && !changelist.del.has(path)
    );
    const vanishedAdds = missing.filter((path) => changelist.add.has(path));
    if (newDeletes.length || vanishedAdds.length) {
      updateChangelist((next) => {
        newDeletes.forEach((path) => next.del.add(path));
        vanishedAdds.forEach((path) => next.add.delete(path));
      });
      if (newDeletes.length) {
        log(true, `detected files deleted outside the app, marked for delete: ${newDeletes.join(', ')}`);
      }
    }
    // Rebuild the manifest for what is on disk now, keeping each file's base
    // hash: confirmed at head when local == server head, otherwise the
    // previously-recorded base (an edit keeps the version it was based on).
    const next = new Map();
    for (const path of localFiles.keys()) {
      const server = serverFiles.get(path);
      const local = localFiles.get(path);
      if (server?.hash && local?.hash && local.hash === server.hash) {
        next.set(path, server.hash);
      } else if (known.has(path)) {
        next.set(path, known.get(path));
      } else {
        next.set(path, null);
      }
    }
    // Only persist / restate when the rebuilt manifest actually differs from
    // the one we read. `known` is now the in-memory baseHashes (in the dep
    // array), so an unconditional setBaseHashes(next) would re-trigger this
    // effect with a fresh Map every run — an infinite loop. This equality guard
    // makes the effect idempotent once the manifest matches disk reality.
    let same = next.size === known.size;
    if (same) {
      for (const [path, hash] of next) {
        if (!known.has(path) || known.get(path) !== hash) {
          same = false;
          break;
        }
      }
    }
    if (!same) {
      persistManifest(changelistKey, next);
      setBaseHashes(next);
    }
  }, [localListing, localFiles, serverFiles, account, changelistKey, changelist, updateChangelist, log, busy, baseHashes, manifestReady, changelistReady]);

  // ---- selection → per-action candidate files -------------------------------

  const scopes = useMemo(() => selectionScopes(selection, rootId), [selection, rootId]);
  const hasScope = scopes.hasRoot || scopes.files.length > 0 || scopes.folders.length > 0;

  const isModified = useCallback(
    (path) => {
      const local = localFiles.get(path);
      const server = serverFiles.get(path);
      if (!local?.hash || !server?.hash || changelist.del.has(path)) return false;
      if (local.hash === server.hash) return false;
      // Differs from head, but matches the version it was pulled at → the file
      // is merely Outdated (pull to fix), not locally modified: it must not
      // become an edit in the submit modal or a revert candidate.
      const base = baseHashes.get(path);
      if (base && local.hash === base) return false;
      return true;
    },
    [localFiles, serverFiles, changelist.del, baseHashes]
  );

  // Raw per-path engine statuses (Task 1's compute_status, unsuppressed —
  // NOT the chip map above, which hides some 'notAdded' entries for display).
  // The candidate planners in data/fileVerbs.js classify off this, the same
  // ground truth cambium-core's own executor verbs read.
  const rawStatusByPath = useMemo(
    () => new Map(engine.statuses.map((e) => [e.path, e.status])),
    [engine.statuses]
  );

  const candidates = useMemo(() => {
    // Server paths in scope not locked by anyone (locks map still available
    // and fine to read). A path already marked for delete is excluded — same
    // as before this task — since checking it out doesn't make sense while
    // it's staged for removal.
    const checkout = [];
    for (const path of serverFiles.keys()) {
      if (!underScopes(path, scopes)) continue;
      if (rawStatusByPath.get(path) === 'markedForDelete') continue;
      if (!locks[path]) checkout.push(path);
    }

    // Every local-only path (in localFiles, not on the server) under scope —
    // classifyAddTargets and classifyDeleteTargets both need this raw set.
    const localOnlyScope = [];
    for (const path of localFiles.keys()) {
      if (!underScopes(path, scopes) || serverFiles.has(path)) continue;
      localOnlyScope.push(path);
    }
    const { add, ignoredOnly } = classifyAddTargets(localOnlyScope, rawStatusByPath);
    const unadd = localOnlyScope.filter((path) => rawStatusByPath.get(path) === 'markedForAdd');
    // ignoredOnly means every local-only file in scope is on the ignore list
    // (the engine reports no status at all for one) — track the full scope so
    // the "on the ignore list" info dialog can count/pluralize correctly.
    const ignoredSelected = ignoredOnly ? localOnlyScope : [];

    const scopePaths = new Set();
    for (const path of serverFiles.keys()) if (underScopes(path, scopes)) scopePaths.add(path);
    for (const path of localFiles.keys()) if (underScopes(path, scopes)) scopePaths.add(path);
    const {
      engineFiles,
      engineFolders,
      localOnlyFiles: delLocalOnly,
      localOnlyFolders: delLocalOnlyFolders,
      needsConfirm: delNeedsConfirm,
    } = classifyDeleteTargets([...scopePaths], scopes.folders, serverFiles, localFiles, rawStatusByPath);
    const del = engineFiles;
    // A folder already fully marked for delete shouldn't be re-offered.
    const delFolders = engineFolders.filter((folder) => !isFolderMarked(folder));

    const revert = [];
    // Modified-without-checkout is revertable too: restore the server version.
    // Marked-for-add files are NOT revert material — "revert" is reserved for
    // pulling a copy back from the server; un-marking adds is the Don't Add
    // mode of the Add New button.
    const modified = [...serverFiles.keys()].filter(
      (path) => isModified(path) && !lockedByMe(path)
    );
    const revertSources = new Set([
      ...Object.keys(locks).filter(lockedByMe),
      ...changelist.del,
      ...modified,
    ]);
    for (const path of revertSources) {
      if (underScopes(path, scopes)) revert.push(path);
    }
    return {
      checkout,
      add,
      unadd,
      del,
      delLocalOnly,
      delFolders,
      delLocalOnlyFolders,
      delNeedsConfirm,
      ignoredSelected,
      revert,
    };
  }, [serverFiles, localFiles, locks, lockedByMe, scopes, changelist, isFolderMarked, isModified, rawStatusByPath]);

  // Force check-in candidates: every locked file under the selection, whoever
  // holds it. Splitting mine/others happens at action time — the server only
  // allows breaking someone else's lock for admins, and says so itself.
  const forceCheckinPaths = useMemo(
    () => Object.keys(locks).filter((path) => underScopes(path, scopes)),
    [locks, scopes]
  );

  // The Add New button flips to "Don't Add" (subtract icon) only when the
  // selection has marked-for-add files and nothing left to add — any
  // not-added file in the selection means Add New wins.
  const addMode = candidates.add.length === 0 && candidates.unadd.length > 0 ? 'unadd' : 'add';

  // ---- submit ----------------------------------------------------------------

  // Everything in the changelist under a scope: adds, edits (hash drift, checked
  // out or not), delete marks, and marked folders. This is what the submit
  // modal lists; a selection with none of these greys the Submit button out.
  const buildSubmitItems = useCallback(
    (scopesArg) => {
      const items = [];
      for (const path of changelist.add) {
        // Display only — the engine is the authority on what actually
        // submits (Task 4 drops the client-side ignore-list skip here; a
        // stale add mark for a since-ignored path just gets left out of the
        // submit set server-side, same as an ordinary non-candidate path).
        if (underScopes(path, scopesArg) && localFiles.has(path)) {
          items.push({ path, change: 'added', needsCheckout: false });
        }
      }
      for (const path of serverFiles.keys()) {
        if (!underScopes(path, scopesArg) || !isModified(path)) continue;
        // Files locked elsewhere still list — the submit attempts a checkout
        // and the server reports a conflict if it can't be had.
        items.push({ path, change: 'edited', needsCheckout: !lockedByMe(path) });
      }
      for (const path of changelist.del) {
        if (underScopes(path, scopesArg)) {
          items.push({ path, change: 'deleted', needsCheckout: false });
        }
      }
      const folders = [...changelist.delFolders].filter(
        (folder) =>
          scopesArg.hasRoot ||
          scopesArg.folders.includes(folder) ||
          underScopes(folder, scopesArg)
      );
      return { items, folders };
    },
    [changelist, localFiles, serverFiles, isModified, lockedByMe]
  );

  const submitItems = useMemo(() => buildSubmitItems(scopes), [buildSubmitItems, scopes]);
  const allSubmitItems = useMemo(
    () => buildSubmitItems({ hasRoot: true, files: [], folders: [] }),
    [buildSubmitItems]
  );

  const nextRevision = useMemo(
    () => revisions.reduce((max, r) => Math.max(max, r.revision), 0) + 1,
    [revisions]
  );

  const enabled = {
    refresh: !busy,
    pull: !busy, // empty selection still "works": it opens the how-pulling-works popup
    // Submit also waits for manifest AND changelist hydration: both now arrive
    // async from the native store, so for a moment an Outdated file can read
    // as an edit whose base is the current head — submitting then would
    // defeat the server's stale check and overwrite a teammate's newer
    // revision.
    submit:
      !busy &&
      manifestReady &&
      changelistReady &&
      submitItems.items.length + submitItems.folders.length > 0,
    checkout: !busy && candidates.checkout.length > 0,
    addNew:
      !busy &&
      changelistReady &&
      (candidates.add.length > 0 ||
        candidates.unadd.length > 0 ||
        candidates.ignoredSelected.length > 0),
    delete:
      !busy &&
      changelistReady &&
      candidates.del.length +
        candidates.delLocalOnly.length +
        candidates.delFolders.length +
        candidates.delLocalOnlyFolders.length >
        0,
    revert: !busy && changelistReady && candidates.revert.length > 0,
    forceCheckin: !busy && forceCheckinPaths.length > 0,
  };

  // ---- shared plumbing -------------------------------------------------------

  const showError = useCallback((title, text) => {
    setDialog({
      type: 'info',
      icon: 'assets/circle_exclaim.svg',
      color: 'orange',
      title,
      text,
    });
  }, []);

  /** Capped file-list dialog for per-file transfer failures. A mass failure
   * (dead endpoint, dropped link) can produce thousands of identical rows —
   * rendering them all froze the dialog and buried the actual error text. */
  const showTransferErrors = useCallback((title, errors) => {
    const MAX_ROWS = 50;
    const total = errors.length;
    setDialog({
      type: 'filelist',
      variant: 'warning',
      icon: 'assets/circle_exclaim.svg',
      color: 'orange',
      title,
      text:
        total > MAX_ROWS
          ? `${total.toLocaleString()} files failed. The first ${MAX_ROWS} are shown below:`
          : 'These files could not be transferred:',
      rows: errors.slice(0, MAX_ROWS).map((e) => ({ path: e.path || '(request)', note: e.error })),
      columnLabel: 'Problem',
      actions: [{ label: 'Okay', action: () => setDialog(null) }],
    });
  }, []);

  const showStatusInfo = useCallback((status) => {
    setDialog({
      type: 'info',
      icon: status.icon,
      color: status.color,
      title: status.title,
      text: status.text,
    });
  }, []);

  const startPulse = useCallback((relPaths) => {
    clearTimeout(pulseTimer.current);
    setPulse(new Set(relPaths));
    pulseTimer.current = setTimeout(() => setPulse(null), PULSE_MS);
  }, []);

  /** Download depot head content for `paths` into the workspace. Written
   * read-only unless the allow-unchecked-edits setting says otherwise.
   * Small files ride batched archive requests (archiveUrl + sizeBytes feed
   * the Rust engine's partitioner); progress events drive the toolbar strip
   * and the not-added flicker suppression. */
  const syncPaths = useCallback(
    async (paths, files) => {
      if (!workspacePath) return { ok: true, changed: [], created: [], dirsCreated: [], errors: [] };
      // total: 0 until the engine's first event — the engine reports the
      // POST-SKIP total, so echoing paths.length here would show a number
      // that visibly "drops" once real events arrive.
      setTransferState({
        pullingPaths: new Set(paths),
        transfer: paths.length ? { kind: 'pull', path: '', done: 0, total: 0 } : null,
      });
      try {
        return await pullWorkspaceFiles(
          {
            folderPath: workspacePath,
            files: paths.map((path) => ({
              path,
              url: api.fileContentUrl(path),
              hash: files.get(path)?.hash,
              sizeBytes: files.get(path)?.sizeBytes,
            })),
            headers: api.authHeaders(),
            writable: allowUncheckedEdits,
            archiveUrl: `${api.baseUrl}/api/files/archive`,
          },
          ({ kind, path, done, total, bytesDone, bytesTotal }) =>
            setTransferState({ transfer: { kind, path, done, total, bytesDone, bytesTotal } }),
          getPin(api.baseUrl)
        );
      } finally {
        setTransferState({ transfer: null, pullingPaths: null });
      }
    },
    [workspacePath, api, allowUncheckedEdits]
  );

  /** Record the depot head hash we just wrote to disk as each path's base
   * version — the copy on disk is now based on that revision. Merges into the
   * hydrated in-memory manifest (via a functional update, so no stale closure)
   * and persists it. No-ops before hydration: writing the merge over a
   * pre-hydration empty base map would drop every existing entry from the
   * native store. Callers here only ever run from a user action after load, so
   * this guard is belt-and-suspenders. */
  const recordBaseHashes = useCallback(
    (paths, files) => {
      if (!paths?.length) return;
      if (!manifestReady) {
        log(false, 'skipped recording base hashes: synced manifest not hydrated yet');
        return;
      }
      setBaseHashes((prev) => {
        const map = new Map(prev);
        let changed = false;
        for (const path of paths) {
          const hash = files.get(path)?.hash;
          if (hash) {
            map.set(path, hash);
            changed = true;
          }
        }
        if (changed) persistManifest(changelistKey, map);
        return changed ? map : prev;
      });
    },
    [changelistKey, manifestReady, log]
  );

  // ---- toolbar actions -------------------------------------------------------

  // The GUI's session/selection state, shaped the way buildConsoleCtx (and
  // therefore dispatchFileCommand) expects — same assembly as ConsolePanel's
  // `conn` (components/panels/ConsolePanel.jsx).
  const conn = useMemo(
    () => ({ api, account, deviceId, activeProjectId, projectName, workspacePath }),
    [api, account, deviceId, activeProjectId, projectName, workspacePath]
  );
  // dispatchFileCommand's refreshers: fans a successful verb's `changed` hints
  // out to the depot tree / revisions / locks, plus a full workspace reload
  // whenever `changed` includes 'files'.
  const refreshers = useMemo(
    () => ({ refreshDepotTree, refreshRevisions, refreshLocks, reloadWorkspace }),
    [refreshDepotTree, refreshRevisions, refreshLocks, reloadWorkspace]
  );

  // Runs the executor's `checkout` verb: it syncs any missing/outdated
  // content to disk and records the manifest base itself (Task 3's brief
  // verified this against executor.rs's `checkout` before this task was
  // written), so this callback no longer does any of that orchestration
  // client-side — just build the line, dispatch, and rehydrate.
  const doCheckout = useCallback(async () => {
    const paths = candidates.checkout;
    if (!paths.length || !api) return;
    setBusy(true);
    try {
      const line = buildCommandLine('checkout', paths);
      const result = await dispatchFileCommand(line, conn, refreshers);
      if (!result.ok) {
        showError('Cannot check out', result.human);
        log(false, result.human);
        return;
      }
      log(true, result.human);
      rehydrate();
    } finally {
      setBusy(false);
    }
  }, [candidates.checkout, api, conn, refreshers, showError, log, rehydrate]);

  // Add-mode runs the executor's `add` verb; unadd-mode (the "Don't Add"
  // toolbar state) un-marks via `revert` — the executor's revert never needs
  // `-f` to un-mark an add (the file sits on disk untouched either way), so
  // this never shows a confirm dialog.
  const doAddNew = useCallback(async () => {
    if (addMode === 'unadd') {
      const paths = candidates.unadd;
      if (!paths.length) return;
      setBusy(true);
      try {
        const line = buildCommandLine('revert', paths);
        const result = await dispatchFileCommand(line, conn, refreshers);
        if (!result.ok) {
          showError('Cannot remove from add', result.human);
          log(false, result.human);
          return;
        }
        log(true, result.human);
        rehydrate();
      } finally {
        setBusy(false);
      }
      return;
    }
    const paths = candidates.add;
    if (!paths.length) {
      // Nothing addable in the selection. If it's made up only of ignore-listed
      // files, explain why — a mixed selection just adds the rest silently.
      if (candidates.ignoredSelected.length) {
        setDialog({
          type: 'info',
          icon: 'assets/circle_exclaim.svg',
          color: 'grey',
          title: 'On the ignore list',
          text:
            `${candidates.ignoredSelected.length === 1 ? 'This file is' : 'These files are'} ` +
            'on the ignore list (ignore.txt), so they can’t be added to the server. ' +
            'An ignore list is usually used to keep things like personal settings, ' +
            'caches, or temporary and generated files out of the shared repository.',
        });
      }
      return;
    }
    setBusy(true);
    try {
      const line = buildCommandLine('add', paths);
      const result = await dispatchFileCommand(line, conn, refreshers);
      if (!result.ok) {
        showError('Cannot add', result.human);
        log(false, result.human);
        return;
      }
      log(true, result.human);
      rehydrate();
    } finally {
      setBusy(false);
    }
  }, [addMode, candidates.add, candidates.unadd, candidates.ignoredSelected, conn, refreshers, showError, log, rehydrate]);

  // Splits between the executor's `delete` verb (server-backed content —
  // candidates.del/delFolders, from classifyDeleteTargets) and the direct
  // `deleteWorkspaceFiles` path (never-added content — candidates.delLocalOnly/
  // delLocalOnlyFolders; the executor refuses a never-added FILE target
  // non-interactively). Both run when the selection is mixed.
  const doDelete = useCallback(() => {
    const engineFiles = candidates.del;
    const engineFolders = candidates.delFolders;
    const localOnlyFiles = candidates.delLocalOnly;
    const localOnlyFolders = candidates.delLocalOnlyFolders;
    if (!engineFiles.length && !engineFolders.length && !localOnlyFiles.length && !localOnlyFolders.length) {
      return;
    }

    // Local files living under a purely-local folder are unrecoverable too —
    // there is no server copy behind them to revert to. Used only for the
    // confirm dialog's wording below; candidates.delNeedsConfirm already
    // carries the same decision (classifyDeleteTargets).
    const localOnlyFolderFiles = localOnlyFolders.length
      ? [...localFiles.keys()].filter((path) =>
          localOnlyFolders.some((folder) => path.startsWith(`${folder}/`))
        )
      : [];

    const run = async () => {
      setBusy(true);
      try {
        if (localOnlyFiles.length || localOnlyFolders.length) {
          // Marking for delete removes the local copy right away; purely-local
          // folders are removed from disk the same way, but never enter the
          // changelist — there is nothing on the server to submit.
          if (workspacePath) {
            await deleteWorkspaceFiles({
              folderPath: workspacePath,
              paths: localOnlyFiles,
              folders: localOnlyFolders,
            });
          }
          // Awaited: this and the engine `delete` call below both persist to
          // the SAME native changelist file (see the rehydrateNonce comment
          // above) — letting Rust's own save race an in-flight JS write here
          // could clobber whichever one lands second.
          await updateChangelist((next) => {
            localOnlyFiles.forEach((path) => next.add.delete(path));
            // Clear any marked-for-add files that lived inside a deleted
            // local-only folder — they're gone from disk now.
            localOnlyFolders.forEach((folder) => {
              const prefix = `${folder}/`;
              for (const path of [...next.add]) {
                if (path.startsWith(prefix)) next.add.delete(path);
              }
            });
          });
          // Drop these paths' synced-hash manifest entries right now, rather
          // than waiting for the next workspace rescan to notice they're gone
          // (same reasoning as before this task: a stale "synced" entry could
          // make the teammate-deletion detector think this user still has an
          // orphaned copy of a file they themselves just deleted). Awaited for
          // the same clobber-avoidance reason as the changelist write above.
          let persistedManifest = Promise.resolve();
          setBaseHashes((prev) => {
            const manifest = new Map(prev);
            let changed = false;
            for (const path of localOnlyFiles) {
              if (manifest.delete(path)) changed = true;
            }
            for (const folder of localOnlyFolders) {
              const prefix = `${folder}/`;
              for (const path of [...manifest.keys()]) {
                if (path.startsWith(prefix)) {
                  manifest.delete(path);
                  changed = true;
                }
              }
            }
            if (changed) persistedManifest = persistManifest(changelistKey, manifest);
            return changed ? manifest : prev;
          });
          await persistedManifest;
          if (localOnlyFolders.length) {
            log(true, `deleted local-only folder(s): ${localOnlyFolders.join(', ')}`);
          }
          if (localOnlyFiles.length) log(true, `deleted (never added): ${localOnlyFiles.join(', ')}`);
        }

        if (engineFiles.length || engineFolders.length) {
          const line = buildCommandLine('delete', [...engineFiles, ...engineFolders]);
          const result = await dispatchFileCommand(line, conn, refreshers);
          if (!result.ok) {
            showError('Cannot delete', result.human);
            log(false, result.human);
            return;
          }
          log(true, result.human);
          rehydrate();
        } else {
          await reloadWorkspace();
        }
      } finally {
        setBusy(false);
      }
    };

    // Content with no server copy behind it — never-added files, plus files
    // inside a purely-local folder — is unrecoverable, so it gets the red
    // warning. Server-backed deletes are revertable and go straight through.
    // An empty local folder has nothing to lose, so it skips the warning.
    if (candidates.delNeedsConfirm) {
      const unrecoverable = [...localOnlyFiles, ...localOnlyFolderFiles];
      const what =
        unrecoverable.length === 1 ? unrecoverable[0] : `${unrecoverable.length} files`;
      setDialog({
        type: 'confirm',
        title: 'Are you sure?',
        text:
          `${what} ${unrecoverable.length === 1 ? 'has' : 'have'} not been added to the ` +
          'server, so deleting cannot be undone with a revert.',
        onConfirm: () => {
          setDialog(null);
          run();
        },
      });
      return;
    }
    run();
  }, [
    candidates.del,
    candidates.delLocalOnly,
    candidates.delFolders,
    candidates.delLocalOnlyFolders,
    candidates.delNeedsConfirm,
    localFiles,
    workspacePath,
    updateChangelist,
    changelistKey,
    conn,
    refreshers,
    showError,
    log,
    reloadWorkspace,
    rehydrate,
  ]);

  // Whether reverting `path` would discard real local changes — the same
  // hash-diff check doRevert used pre-task-3, kept here purely to decide
  // whether to show the confirm dialog and which paths to list in it; the
  // executor's own `-f` gate is the actual enforcement (has_real_local_changes
  // in executor.rs), and refuses with the same reasoning if this ever
  // disagreed with it.
  const hasUnsyncedChanges = useCallback(
    (path) =>
      localFiles.has(path) &&
      serverFiles.has(path) &&
      localFiles.get(path)?.hash !== serverFiles.get(path)?.hash,
    [localFiles, serverFiles]
  );

  // Runs the executor's `revert` verb. Un-marking an add never needs `-f`
  // (see doAddNew's unadd-mode above), but restoring a modified/deleted file
  // or releasing a checkout does when the on-disk copy carries real local
  // edits — planRevert decides whether to ask first; `-f` is appended to the
  // line only once the user confirms.
  const doRevert = useCallback(() => {
    const paths = candidates.revert;
    if (!paths.length) return;
    const { line, needsConfirm } = planRevert(paths, hasUnsyncedChanges);

    const run = async (forced) => {
      setBusy(true);
      try {
        const result = await dispatchFileCommand(forced ? `${line} -f` : line, conn, refreshers);
        if (!result.ok) {
          showError('Cannot revert', result.human);
          log(false, result.human);
          return;
        }
        log(true, result.human);
        rehydrate();
      } finally {
        setBusy(false);
      }
    };

    if (needsConfirm) {
      // Any revert that throws away local edits gets the red warning — whether
      // the file was checked out or modified without a checkout.
      const withChanges = paths.filter(hasUnsyncedChanges);
      setDialog({
        type: 'filelist',
        variant: 'danger',
        icon: 'assets/circle_exclaim.svg',
        color: 'red',
        title: 'Are you sure?',
        text: 'The following files have changes that would be lost:',
        rows: withChanges.map((path) => ({ path })),
        actions: [
          { label: 'Go back', action: () => setDialog(null) },
          {
            label: 'I am sure',
            danger: true,
            action: () => {
              setDialog(null);
              run(true);
            },
          },
        ],
      });
      return;
    }
    run(false);
  }, [candidates.revert, hasUnsyncedChanges, conn, refreshers, showError, log, rehydrate]);

  /**
   * Submit the included changelist entries as one revision, via the
   * executor's `submit` verb (Task 4, charter sub-task 18d): the engine owns
   * every pre-check that used to run here (naming, staleness), the upload
   * orchestration, and the conflict split, so this just builds the line,
   * dispatches, feeds the toolbar progress strip, and maps the result to a
   * dialog. Returns `{ok:true, conflicts}` on success (whether or not some
   * items conflicted — `submitDialogs` already turned any conflicts into a
   * popup); `{ok:false, blocked:true}` on ANY executor refusal or failure —
   * this folds what used to be a "hard failure keeps the modal open" edge
   * into the same blocked path, so a dead upload or a rejected submit always
   * closes the modal and reveals the dialog underneath, same as a client-side
   * pre-check block always did.
   */
  // Paths this client just submitted (adds/edits). Right after a submit the
  // manifest records them as synced but the depot-tree snapshot can lag a beat,
  // making them look "synced but missing from the server" — a false teammate-
  // deletion. Suppressed in that detector until serverFiles confirms them
  // (then cleared, so a later genuine teammate delete still surfaces).
  const recentlySubmitted = useRef(new Set());

  const performSubmit = useCallback(
    async (items, folders, description) => {
      if (!api || !workspacePath || (!items.length && !folders.length)) return { ok: false };

      // Defense in depth behind the enabled.submit gate: without hydrated
      // changelist/manifest state, this could race a just-swapped identity's
      // still-loading data. Blocked (not failed): the modal closes so this
      // popup shows.
      if (!manifestReady || !changelistReady) {
        showError(
          'Still syncing',
          'Your workspace sync state is still loading. Try submitting again in a moment.'
        );
        return { ok: false, blocked: true };
      }

      setBusy(true);
      try {
        const { line } = planSubmit(items, folders, description);
        const hasUploads = items.some((item) => item.change !== 'deleted');
        setTransferState({ transfer: hasUploads ? { kind: 'upload', path: '', done: 0, total: 0 } : null });
        let result;
        try {
          result = await dispatchFileCommand(line, conn, refreshers, {
            onProgress: (p) => setTransferState({ transfer: p }),
          });
        } finally {
          setTransferState({ transfer: null });
        }

        const dialog = submitDialogs(result, locks, getUser);
        if (dialog) {
          setDialog({ ...dialog, actions: [{ label: 'Okay', action: () => setDialog(null) }] });
        }

        if (!result.ok) {
          log(false, result.human);
          return { ok: false, blocked: true };
        }

        log(true, result.human);
        for (const item of items) {
          if (item.change !== 'deleted') recentlySubmitted.current.add(item.path);
        }
        rehydrate();
        return { ok: true, conflicts: result.data?.conflicts ?? [] };
      } finally {
        setBusy(false);
      }
    },
    [api, workspacePath, manifestReady, changelistReady, conn, refreshers, locks, getUser, log, rehydrate, showError]
  );

  // ---- server-side deletions by teammates -------------------------------------
  // When a refresh reveals files that vanished from the depot but still exist
  // locally, ask the user whether to drop or keep their copies. Own deletes
  // never trigger this (their local copy is already gone at mark time).
  const localFilesRef = useRef(localFiles);
  localFilesRef.current = localFiles;
  const prevServerFiles = useRef(null);
  // Files we've already prompted about, per (server, user, workspace folder) —
  // persisted so "Save Them" doesn't re-open the popup on every subsequent
  // refresh *or* the next time this identity reconnects.
  const answeredDeletions = useRef(loadAnsweredDeletions(changelistKey));
  // Every session-scoped anti-nag ref (this one, plus the checkout/stale
  // warnings below) is keyed to a single identity: whoever is logged in right
  // now, on this workspace folder. If the identity changes — a different
  // account connects, or the same account switches workspace folders — none
  // of that state may carry over: it was recorded for a *different* device's
  // view of the depot, and a teammate's delete must never be judged against
  // (or suppressed by) the previous user's history. Re-derived from the
  // per-identity persisted store below rather than just cleared, so a user
  // who already answered a prompt in an earlier session isn't re-nagged.
  useEffect(() => {
    prevServerFiles.current = null;
    answeredDeletions.current = loadAnsweredDeletions(changelistKey);
    recentlySubmitted.current = new Set();
  }, [changelistKey]);
  useEffect(() => {
    if (!depotTree || !account) {
      prevServerFiles.current = null;
      return;
    }
    // The manifest-diff branch below decides which files land in a destructive
    // "deleted on the server" prompt, so it must read the CURRENT identity's
    // hydrated manifest. Before hydration the map is empty — bail without even
    // recording an in-session baseline, so the first real run (once ready)
    // diffs against a correct prevServerFiles snapshot.
    if (!manifestReady) return;
    // The engine's serverFiles fills asynchronously (compute_status round
    // trip, see the engine effect above): an empty map means "not computed
    // yet", not "the server has no files" — treating it as the latter would
    // flag every synced file as deleted by a teammate. Mirrors the
    // local-vanish effect's `serverFiles.size === 0` guard above.
    if (serverFiles.size === 0) return;
    const prev = prevServerFiles.current;
    prevServerFiles.current = serverFiles;
    const local = localFilesRef.current;
    // A just-submitted path is confirmed once the refreshed depot tree shows
    // it; stop suppressing it then, so a later genuine teammate delete of the
    // same path still surfaces.
    for (const path of [...recentlySubmitted.current]) {
      if (serverFiles.has(path)) recentlySubmitted.current.delete(path);
    }
    // computeServerDeletions unions the in-session diff (was in the depot a
    // moment ago, now gone) with the persisted-manifest diff (synced base hash,
    // still on disk, no longer in the depot — catches a fresh login/first
    // refresh), minus own deletes, already-answered paths, and files we just
    // submitted (whose depot-tree entry hasn't landed yet).
    const gone = computeServerDeletions({
      prevServerFiles: prev,
      serverFiles,
      localFiles: local,
      baseHashes,
      changelistDel: changelist.del,
      answered: answeredDeletions.current,
      recentlySubmitted: recentlySubmitted.current,
    });
    if (!gone.length || !warningsEnabled) return;
    // Remember we asked so a "Save Them" answer isn't re-prompted every
    // refresh or reconnect — persisted per identity, not just this session.
    gone.forEach((path) => answeredDeletions.current.add(path));
    saveAnsweredDeletions(changelistKey, answeredDeletions.current);
    setDialog({
      type: 'filelist',
      variant: 'info',
      icon: 'assets/delete.svg',
      color: 'grey',
      title: 'Files deleted on the server',
      text:
        'Another member deleted these files from the server, but you still have a ' +
        'copy on this device. Would you like to delete your local copies or save them?',
      rows: gone.map((path) => ({ path })),
      actions: [
        {
          label: 'Delete Local Copies',
          action: async () => {
            setDialog(null);
            if (workspacePath) {
              await deleteWorkspaceFiles({ folderPath: workspacePath, paths: gone });
            }
            log(true, `deleted local copies of server-removed files: ${gone.join(', ')}`);
            await reloadWorkspace();
          },
        },
        {
          label: 'Save Them',
          action: () => {
            setDialog(null);
            log(true, `kept local copies of server-removed files: ${gone.join(', ')}`);
          },
        },
      ],
    });
  }, [depotTree, serverFiles, account, baseHashes, manifestReady, changelistKey, changelist.del, workspacePath, log, reloadWorkspace, warningsEnabled]);

  // ---- pending-change conflict warnings ----------------------------------------
  // (a) Orange: a teammate checked out a file this user has uncommitted local
  //     changes on (edited without checking out) — the lock means they intend
  //     to change it, so the user should resolve their edit now and check files
  //     out before editing in the future.
  // (b) Red: a newer revision of a file the user has local changes on landed on
  //     the server — their edit is now stale and a submit would be refused.
  // Both are one-shot per (path, cause) so the 20s poll doesn't nag. Persisted
  // (like answeredDeletions above), not just an in-memory ref: switching to
  // another top-level workspace page (Organization, Time Tracking) and back
  // to Revision Control unmounts/remounts this whole hook (each page has its
  // own panel layout — see layoutEngine.js), which would otherwise wipe a
  // plain ref and re-show a warning already dismissed for an unchanged
  // condition. Reset (re-derived from storage, not just cleared) whenever the
  // identity (server, user, workspace folder) changes so a different user's
  // checkout/staleness history can never suppress or leak into this one's.
  const warnedCheckouts = useRef(loadWarnedSet(WARNED_CHECKOUTS_KEY, changelistKey));
  useEffect(() => {
    warnedCheckouts.current = loadWarnedSet(WARNED_CHECKOUTS_KEY, changelistKey);
  }, [changelistKey]);
  useEffect(() => {
    if (!account || !localListing || !me) return;
    // isModified() consults baseHashes to separate a genuinely Modified file
    // from a merely Outdated one. A pre-hydration empty map makes every Outdated
    // file look Modified, which would raise a false conflict popup AND persist
    // its one-shot key — permanently suppressing the real warning. Wait for the
    // manifest.
    if (!manifestReady) return;
    const rows = [];
    for (const [path, lock] of Object.entries(locks)) {
      const mine =
        lock.username.toLowerCase() === me.username.toLowerCase() &&
        lock.deviceId === me.deviceId;
      if (mine || !isModified(path)) continue;
      const key = `${path}::${lock.username}::${lock.deviceId}`;
      if (warnedCheckouts.current.has(key)) continue;
      if (!warningsEnabled) continue; // notification only — don't mark as warned
      warnedCheckouts.current.add(key);
      saveWarnedSet(WARNED_CHECKOUTS_KEY, changelistKey, warnedCheckouts.current);
      rows.push({ path, note: getUser(lock.username)?.displayName ?? lock.username });
    }
    if (!rows.length) return;
    setDialog({
      type: 'filelist',
      variant: 'warning',
      icon: 'assets/circle_exclaim.svg',
      color: 'orange',
      title: 'Your changes conflict with a checkout',
      text:
        'You have changes on these files, but they are not checked out to you and ' +
        'a teammate just checked them out — they are about to change files you ' +
        'edited. Back up your changes and revert the files, then re-apply your ' +
        'work after the lock is released. Make sure files are checked out before ' +
        'you edit them.',
      rows,
      columnLabel: 'Checked out by',
      actions: [{ label: 'Okay', action: () => setDialog(null) }],
    });
  }, [locks, account, localListing, me, isModified, getUser, warningsEnabled, changelistKey, manifestReady]);

  const warnedStale = useRef(loadWarnedSet(WARNED_STALE_KEY, changelistKey));
  useEffect(() => {
    warnedStale.current = loadWarnedSet(WARNED_STALE_KEY, changelistKey);
  }, [changelistKey]);
  useEffect(() => {
    if (!account || !localListing || serverFiles.size === 0) return;
    // Keyed on baseHashes to tell a stale edit from an up-to-date one; a
    // pre-hydration empty map has no bases to judge against (and persisting a
    // one-shot key now would suppress the true warning later). Wait for it.
    if (!manifestReady) return;
    const rows = [];
    for (const [path, server] of serverFiles) {
      const local = localFiles.get(path);
      const base = baseHashes.get(path);
      if (!local?.hash || !server?.hash || !base) continue;
      if (changelist.del.has(path)) continue;
      if (server.hash === base) continue; // server hasn't moved past this copy's base
      if (local.hash === base) continue; // no local changes — the Outdated chip covers it
      if (local.hash === server.hash) continue; // already matches the new head
      const key = `${path}::${server.hash}`;
      if (warnedStale.current.has(key)) continue;
      if (!warningsEnabled) continue; // notification only — enforcement lives in performSubmit
      warnedStale.current.add(key);
      saveWarnedSet(WARNED_STALE_KEY, changelistKey, warnedStale.current);
      rows.push({
        path,
        note: server.headRevision != null ? `revision ${server.headRevision}` : 'new revision',
      });
    }
    if (!rows.length) return;
    setDialog({
      type: 'filelist',
      variant: 'danger',
      icon: 'assets/circle_exclaim.svg',
      color: 'red',
      title: 'Your changes are based on an old revision',
      text:
        'A new revision of these files was submitted to the server, but you have ' +
        'local changes based on an older version — you will not be able to submit ' +
        'them as they are. Back up your changes, revert the files to get the new ' +
        'versions, then re-apply your work after checking them out.',
      rows,
      columnLabel: 'Server now has',
      actions: [{ label: 'Okay', action: () => setDialog(null) }],
    });
  }, [serverFiles, localFiles, baseHashes, changelist.del, account, localListing, warningsEnabled, changelistKey, manifestReady]);

  // ---- rollback (History panel → revision modal → revert) ----------------------
  /**
   * Roll `paths` back to their state at `revision`. Each path becomes its own
   * forward revision on the server ("Rollback <path> to revision <N>"); the
   * workspace copies are then synced to the new head (or deleted, when the
   * rollback target is "file didn't exist"), and a file-list popup reports
   * per-file results.
   */
  const performRollback = useCallback(
    async (paths, revision) => {
      if (!api || !paths?.length) return;
      setBusy(true);
      try {
        const results = [];
        let lockTable = null;
        for (const path of paths) {
          const result = await api.rollbackFile(path, revision);
          if (result.ok) {
            lockTable = result.locks ?? lockTable;
            results.push({
              path,
              ok: true,
              change: result.change?.change,
              revision: result.revision?.revision,
            });
          } else {
            results.push({ path, ok: false, error: result.error });
          }
        }
        if (lockTable) setLocks(lockTable);

        const succeeded = results.filter((r) => r.ok);
        const fresh = await refreshDepotTree();
        refreshRevisions();
        if (succeeded.length && fresh) {
          const freshFiles = flattenServerListing(fresh.children);
          const removed = succeeded
            .filter((r) => r.change === 'deleted')
            .map((r) => r.path);
          const restore = succeeded
            .map((r) => r.path)
            .filter((path) => !removed.includes(path) && freshFiles.has(path));
          // We deleted these on purpose — don't let the teammate-deletion
          // detector re-prompt about them (persisted, so this holds across
          // reconnects too, same as a manual "Save Them"/"Delete" answer).
          if (removed.length) {
            removed.forEach((path) => answeredDeletions.current.add(path));
            saveAnsweredDeletions(changelistKey, answeredDeletions.current);
          }
          if (restore.length) {
            const syncResult = await syncPaths(restore, freshFiles);
            recordBaseHashes(pulledCleanly(restore, syncResult), freshFiles);
          }
          if (removed.length && workspacePath) {
            await deleteWorkspaceFiles({ folderPath: workspacePath, paths: removed });
          }
          updateChangelist((next) => {
            succeeded.forEach(({ path }) => {
              next.add.delete(path);
              next.del.delete(path);
            });
          });
          await reloadWorkspace();
          startPulse([...restore, ...removed]);
        }

        // A file whose content already matches the target revision ("already
        // at that content") or that was absent then and is absent now
        // ("nothing to roll back") is NOT a failure — it is already in the
        // requested state, so there is simply nothing to do. Treating these as
        // failures made a whole-repo restore look broken when files were
        // deleted and re-added identically (their old revision equals the
        // current head). Only a real block — a teammate's lock, or content the
        // server no longer retains — counts as a failure.
        const NOOP_ERRORS = new Set(['already at that content', 'nothing to roll back']);
        const noops = results.filter((r) => !r.ok && NOOP_ERRORS.has(r.error));
        const failures = results.filter((r) => !r.ok && !NOOP_ERRORS.has(r.error));
        const atTarget = succeeded.length + noops.length;
        const friendly = (error) =>
          error === 'already at that content'
            ? 'Already at that revision'
            : error === 'nothing to roll back'
              ? 'Already absent at that revision'
              : error;
        log(
          !failures.length,
          `rollback to revision ${revision}: ${succeeded.length} rolled back` +
            (noops.length ? `, ${noops.length} already there` : '') +
            (failures.length ? `, ${failures.length} blocked` : '')
        );
        // The rollback itself (server calls, workspace sync, changelist and
        // lock bookkeeping above) always runs — the warnings setting only
        // silences this result notification, never the underlying action.
        // No one-shot ref needed here: this fires once per explicit rollback
        // the user asked for, not from a poll that could re-fire for the same
        // condition, so there's nothing to de-duplicate.
        if (warningsEnabled) {
          setDialog({
            type: 'filelist',
            variant: failures.length ? 'warning' : 'info',
            icon: failures.length ? 'assets/circle_exclaim.svg' : 'assets/circle_check.svg',
            color: failures.length ? 'orange' : 'pale-green',
            title: failures.length
              ? atTarget
                ? 'Rollback partially complete'
                : 'Rollback blocked'
              : 'Rollback complete',
            text: failures.length
              ? atTarget
                ? `${atTarget} ${atTarget === 1 ? 'file is' : 'files are'} now at revision ${revision}; ` +
                  `${failures.length} could not be — see below.`
                : `No files could be rolled back to revision ${revision} — see why below.`
              : succeeded.length
                ? `Restored ${atTarget === 1 ? 'this file' : 'these files'} to revision ${revision}. ` +
                  'Your workspace copies were updated to match.'
                : `${atTarget === 1 ? 'This file was' : 'These files were'} already at revision ${revision} — nothing to change.`,
            rows: results.map((r) => ({
              path: r.path,
              note: r.ok ? `rev ${r.revision ?? '—'}` : friendly(r.error),
            })),
            columnLabel: 'Result',
            actions: [{ label: 'Okay', action: () => setDialog(null) }],
          });
        }
      } finally {
        setBusy(false);
      }
    },
    [api, setLocks, refreshDepotTree, refreshRevisions, syncPaths, recordBaseHashes, workspacePath, updateChangelist, changelistKey, reloadWorkspace, startPulse, log, warningsEnabled]
  );

  // Runs the executor's `pull` verb (Task 4, charter sub-task 18d): the
  // engine gathers its own fresh depot tree and applies every exclusion
  // (locked-by-me, delete-marked, ignore-list) itself, so this no longer
  // pre-fetches the tree or pre-filters a target list — it just builds the
  // scope-derived line, dispatches, feeds the toolbar progress strip, and
  // maps the result. A whole-batch download failure (`data.failures`) comes
  // back as a single refused CommandOutput (no partial pulse/log — see
  // `pull_failure_output` in executor.rs), so the failure path only shows
  // the capped filelist dialog; a clean success pulses every path that
  // landed (`data.pulled`, walked up to parent folders the same way the
  // pre-task code did) and rehydrates the changelist/manifest.
  const doPull = useCallback(async () => {
    if (!hasScope) {
      setDialog({ type: 'info', ...PULL_HELP });
      return;
    }
    if (!api) return;
    if (!workspacePath) {
      showError('No workspace folder', 'Set a workspace folder for this server first — pulled files need somewhere to live.');
      return;
    }
    setBusy(true);
    try {
      const { line } = planPull(scopes);
      // Seed the suppression set with the files this pull is about to bring
      // onto disk for the first time (server files in scope not already in
      // localFiles), so a file that lands before its first progress event
      // doesn't flash a 'notAdded' chip. Progress events below keep adding to
      // the live set as the pull proceeds.
      const scopePaths = scopes.hasRoot ? [] : [...scopes.files, ...scopes.folders];
      const livePaths = seedPullingPaths(scopePaths, serverFiles, localFiles);
      setTransferState({ transfer: { kind: 'pull', path: '', done: 0, total: 0 }, pullingPaths: livePaths });
      let result;
      try {
        result = await dispatchFileCommand(line, conn, refreshers, {
          onProgress: (p) => {
            setTransferState({ transfer: p });
            if (p.kind === 'pull' && p.path) {
              livePaths.add(p.path);
              setTransferState({ pullingPaths: new Set(livePaths) });
            }
          },
        });
      } finally {
        setTransferState({ transfer: null, pullingPaths: null });
      }

      if (!result.ok) {
        const failures = result.data?.failures;
        if (failures?.length) {
          showTransferErrors('Pull incomplete', failures);
          // Partial failure: files already landed on disk and manifest was saved by
          // the executor; without refreshing, the workspace tree stays stale until
          // the next poll.
          rehydrate();
          await reloadWorkspace();
        } else {
          showError('Pull failed', result.human);
        }
        log(false, result.human);
        return;
      }

      const pulled = result.data?.pulled ?? [];
      // A directory "changes" when something inside it changed — pulse it too.
      const changedDirs = new Set();
      for (const path of pulled) {
        let parent = path;
        while (parent.includes('/')) {
          parent = parent.slice(0, parent.lastIndexOf('/'));
          changedDirs.add(parent);
        }
      }
      const pulsePaths = [...pulled, ...changedDirs];
      if (pulsePaths.length) startPulse(pulsePaths);
      log(true, result.human);
      rehydrate();
    } finally {
      setBusy(false);
    }
  }, [hasScope, api, workspacePath, scopes, serverFiles, localFiles, conn, refreshers, showError, showTransferErrors, log, startPulse, rehydrate]);

  // Force check-in: release locks WITHOUT submitting — the confirm popup is
  // mandatory because any unsubmitted edits under those locks stay local-only
  // and anyone else can now check the files out.
  const doForceCheckin = useCallback(() => {
    const paths = forceCheckinPaths;
    if (!paths.length) return;
    const mine = paths.filter(lockedByMe);
    const others = paths.filter((path) => !lockedByMe(path));
    const parts = [];
    if (mine.length) parts.push(`${mine.length} checked out by you`);
    if (others.length) parts.push(`${others.length} held by other users (requires admin)`);
    setDialog({
      type: 'confirm',
      title: 'Force check-in?',
      text:
        `This releases the lock on ${paths.length} file(s) without submitting ` +
        `(${parts.join(', ')}). Unsubmitted changes stay on the holder's machine, ` +
        'and anyone can check the files out afterward.',
      onConfirm: async () => {
        setDialog(null);
        setBusy(true);
        try {
          if (mine.length) {
            const result = await api.releaseFiles(mine);
            if (!result?.ok) {
              const error = result?.error ?? 'Could not release your locks.';
              log(false, `force check-in failed: ${error}`);
              showError('Force check-in failed', error);
              return;
            }
            // Log the own-locks half as soon as it lands: if breaking the
            // others' locks fails below, these ARE already released — the
            // exposure the confirm dialog warned about — and the console
            // trail must say so.
            log(true, `force check-in: released ${mine.length} of your lock(s)`);
          }
          if (others.length) {
            const result = await api.forceUnlockFiles(others);
            if (!result?.ok) {
              // Typically the server's 403: breaking another user's lock needs admin.
              const error = result?.error ?? 'Could not break the lock.';
              log(false, `force check-in failed: ${error}`);
              if (mine.length) {
                // Partial outcome: the user's own locks were released above and
                // stay released — say so instead of a blanket "failed".
                showError(
                  'Force check-in partly failed',
                  `Your ${mine.length} lock(s) were released, but the ${others.length} ` +
                    `held by other users were not: ${error}`
                );
              } else {
                showError('Force check-in failed', error);
              }
              return;
            }
            log(true, `force check-in: released ${others.length} lock(s) held by other users`);
          }
        } finally {
          await refreshLocks();
          setBusy(false);
        }
      },
    });
  }, [forceCheckinPaths, lockedByMe, api, refreshLocks, showError, log]);

  const doRefresh = useCallback(async () => {
    setBusy(true);
    try {
      // Refresh the History window (revisions) alongside the depot tree and
      // locks so clicking Refresh — or refocusing the window — brings the
      // revision log up to date just like the file tree.
      await Promise.all([
        refreshDepotTree(),
        refreshLocks(),
        refreshRevisions(),
        reloadWorkspace(),
      ]);
    } finally {
      setBusy(false);
    }
  }, [refreshDepotTree, refreshLocks, refreshRevisions, reloadWorkspace]);

  return {
    statuses,
    serverFiles,
    baseHashes,
    enabled,
    busy,
    transfer,
    dialog,
    setDialog,
    showStatusInfo,
    pulse,
    deletedPaths: changelist.del,
    deletedFolders: changelist.delFolders,
    addMode,
    submitItems,
    allSubmitItems,
    nextRevision,
    performSubmit,
    performRollback,
    actions: {
      refresh: doRefresh,
      pull: doPull,
      checkout: doCheckout,
      add: doAddNew,
      delete: doDelete,
      revert: doRevert,
      forceCheckin: doForceCheckin,
    },
  };
}
