import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { buildFileTree, buildWorkspaceTree, workspaceRootId } from '../../data/fsTree';
import { createEtaTracker, formatEta } from '../../data/transferEta';
import { buildEmptyMenuItems } from '../../data/treeMenus';
import { folderNameError } from '../../data/folderName';
import { setSubtreeExpanded } from '../../data/treeView';
import { expandPathsToReveal, findNodeById, nodeIdsForRelativePaths } from '../../data/treeUtils';
import { versionFraction } from '../../data/versionGraph';
import { useConnection } from '../../context/ConnectionContext';
import { useFileTreeTab } from '../../context/FileTreeTabContext';
import { useServerData } from '../../context/ServerDataContext';
import { useFileControl } from '../../hooks/useFileControl';
import {
  createWorkspaceFolder,
  fileManagerLabel,
  importWorkspacePaths,
  isTauri,
  onFileDrop,
  openPath,
  readWorkspaceTree,
  revealInFileManager,
  selectWorkspaceFolder,
} from '../../platform';
import {
  ChoiceDialog,
  DangerConfirmDialog,
  FileListDialog,
  InfoDialog,
  PromptDialog,
} from '../FileDialogs';
import { SubmitModal } from '../modals/SubmitModal';
import { TreeContextMenu } from '../TreeContextMenu';
import { FileTree, createBrowserState } from '../FileTree';

const TOOLBAR = [
  { id: 'refresh', label: 'Refresh', icon: 'assets/refresh.svg' },
  { id: 'pull', label: 'Pull Latest', icon: 'assets/pull.svg' },
  { id: 'submit', label: 'Submit', icon: 'assets/submit.svg' },
  { id: 'checkout', label: 'Checkout', icon: 'assets/checkout.svg' },
  // 'add' flips to "Don't Add" when the selection is marked-for-add files only
  // (un-marking is not a revert — that word is reserved for restoring server
  // copies, so the Revert button stays dimmed for adds).
  { id: 'add', label: 'Add New', icon: 'assets/add.svg' },
  { id: 'delete', label: 'Delete', icon: 'assets/delete.svg' },
  { id: 'revert', label: 'Revert', icon: 'assets/revert.svg' },
];

function TreeNotice({ children }) {
  return <div className="workspace-tree-notice">{children}</div>;
}

// The Server tab mirrors what actually exists on the server, so only two
// things overlay it: live lock state (shared with everyone) and this client's
// own marked-for-delete entries. Workspace-only states (modified, not added,
// marked for add) never appear there — nothing changes server-side until a
// submit. Marked-for-delete files are hidden from the Workspace tab entirely
// (they no longer exist on disk); their struck-through rows live on the
// Server tab, visible only to the client who marked them.
const SERVER_TREE_KINDS = new Set([
  'checkedOutByMe',
  'lockedByMeElsewhere',
  'lockedByOther',
  'markedForDelete',
]);

// The Workspace tab never renders marked-for-delete paths, even if a copy is
// somehow back on disk (restored externally, or a crash between the changelist
// write and the file removal). The changelist is authoritative until submit or
// revert; the struck-through listing lives on the Server tab alone.
function hideDeleted(nodes, deletedPaths, deletedFolders, rootId) {
  if (!rootId || (!deletedPaths?.size && !deletedFolders?.size)) return nodes;
  const folderMarked = (rel) =>
    deletedFolders && [...deletedFolders].some((f) => rel === f || rel.startsWith(`${f}/`));
  const walk = (list) =>
    list
      .filter((node) => {
        const rel = node.id.startsWith(rootId) ? node.id.slice(rootId.length) : '';
        if (!rel) return true;
        if (node.type === 'file') return !deletedPaths?.has(rel);
        return !folderMarked(rel);
      })
      .map((node) => (node.children ? { ...node, children: walk(node.children) } : node));
  return walk(nodes);
}

// Refreshes rebuild a browser from scratch; carry over the previous one's
// expansion, selection, sort, and scroll so the tree doesn't snap shut.
function carryBrowserState(prev, next) {
  if (!prev) return next;
  const expandedIds = new Set();
  const collect = (node) => {
    if (node.expanded) expandedIds.add(node.id);
    node.children?.forEach(collect);
  };
  prev.tree.forEach(collect);
  const reapply = (node) => {
    if (node.type === 'folder' && expandedIds.has(node.id)) node.expanded = true;
    node.children?.forEach(reapply);
  };
  next.tree.forEach(reapply);
  return {
    ...next,
    selectedIds: prev.selectedIds,
    anchorId: prev.anchorId,
    sort: prev.sort,
    scrollTop: prev.scrollTop,
  };
}

export function RepoPanel() {
  const {
    tab,
    setTab,
    setRepoSelection,
    setWorkspaceSelection,
    setFileHashes,
    activeSelection,
    highlightRequest,
    clearHighlightRequest,
    listChangesRequest,
    clearListChangesRequest,
    rollbackRequest,
    clearRollbackRequest,
    setHasPendingChanges,
    openModal,
  } = useFileTreeTab();
  const { activeProject, projectName, workspacePath: rawWorkspacePath, setWorkspacePath } =
    useConnection();
  const { depotTree, depotError, refreshDepotTree, fileHistoryIndex } = useServerData();
  const workspacePath = rawWorkspacePath?.trim() || '';
  const repoRootId = projectName ? `${projectName}/` : null;
  const wsRootId = workspacePath ? workspaceRootId(workspacePath) : null;

  const [repoBrowser, setRepoBrowser] = useState(null);
  const [workspaceBrowser, setWorkspaceBrowser] = useState(null);
  const [workspaceStatus, setWorkspaceStatus] = useState({ state: 'idle', error: null });
  const [localListing, setLocalListing] = useState(null); // raw children incl. hashes, tagged with .forPath
  const [menu, setMenu] = useState(null); // { x, y, node } | { x, y, empty, selectAll } | null
  const [dropTarget, setDropTarget] = useState(null); // { id, rel } | null
  const [newFolder, setNewFolder] = useState(null); // { parentRel, parentLabel } | null
  const pendingRevealRef = useRef(null); // { workspacePath, rel } to select after a rescan
  const loadSeq = useRef(0);
  // Single-flight guards for loadWorkspace — see the comment inside it for
  // the failure mode this closes off.
  const scanInFlightRef = useRef(null);
  const scanRerunRef = useRef(false);

  // Server tab: rebuild the browser whenever a new depot tree arrives.
  useEffect(() => {
    if (!depotTree || !repoRootId) {
      setRepoBrowser(null);
      return;
    }
    const tree = buildFileTree(depotTree.children, repoRootId, projectName, depotTree.modifiedMs);
    setRepoBrowser((prev) => carryBrowserState(prev, createBrowserState(tree, repoRootId)));
  }, [depotTree, repoRootId, projectName]);

  const loadWorkspace = useCallback(async () => {
    if (!workspacePath) {
      setWorkspaceBrowser(null);
      setLocalListing(null);
      setWorkspaceStatus({ state: 'idle', error: null });
      return;
    }
    // Window-focus refreshes (see Bug F below) fire loadWorkspace on every
    // refocus, including while a long first scan of a large workspace is
    // still in flight. Without coalescing, each refocus started a brand-new
    // readWorkspaceTree call; loadSeq discarded the earlier call's result as
    // stale on arrival, but the earlier scan kept running on the Rust side
    // regardless (its cache snapshot predates the newer call's save), so the
    // overlapping scans piled up re-hashing the entire workspace over and
    // over — CPU pegged, UI stuck on "Reading…" indefinitely. Single-flight
    // per path, with at most one queued trailing rerun, closes that off
    // without touching loadSeq's existing protection against a genuine
    // workspacePath change arriving mid-flight (a different folder is never
    // coalesced — it should supersede the in-flight scan, and still does).
    if (scanInFlightRef.current === workspacePath) {
      scanRerunRef.current = true;
      return;
    }
    scanInFlightRef.current = workspacePath;
    const seq = ++loadSeq.current;
    setWorkspaceStatus({ state: 'loading', error: null });
    try {
      // Hashes let the sync engine tell local edits apart from depot head.
      const result = await readWorkspaceTree(workspacePath, { hashes: true });
      if (seq !== loadSeq.current) return; // stale response, folder changed meanwhile

      if (!result?.ok) {
        setWorkspaceBrowser(null);
        setLocalListing(null);
        setWorkspaceStatus({ state: 'error', error: result?.error ?? 'Could not read folder.' });
        return;
      }
      const tree = buildWorkspaceTree(result.children, workspacePath, result.modifiedMs);
      // Tag with the folder this scan is actually *for* — see safeLocalListing
      // below for why a plain `setLocalListing(result)` isn't safe on its own.
      setLocalListing({ ...result, forPath: workspacePath });
      setWorkspaceBrowser((prev) => carryBrowserState(prev, createBrowserState(tree, tree[0].id)));
      setWorkspaceStatus({ state: 'ready', error: null });
    } finally {
      scanInFlightRef.current = null;
      if (scanRerunRef.current) {
        scanRerunRef.current = false;
        loadWorkspace();
      }
    }
  }, [workspacePath]);

  // workspacePath can change (new connection, folder picked, or — critically
  // — switching to a different account/connection *without* disconnecting
  // first, which leaves this component mounted) in the same render pass that
  // useFileControl recomputes its per-identity storage key from that same
  // workspacePath. localListing, in contrast, only updates once the async
  // disk scan for the new folder resolves — there is no way to make that
  // synchronous. So for at least one render, `workspacePath` reflects the
  // *new* identity while `localListing` still reflects the *previous* one's
  // disk contents. Effects can't fix this after the fact: setting state from
  // an effect only takes hold on a *later* render, but useFileControl's own
  // effects already fired for *this* render using the mismatched pair —
  // long enough to poison the new identity's synced-hash manifest with the
  // previous identity's files, which then reads back as "deleted outside the
  // app" and silently marks them for delete. Guard at render time instead:
  // only ever hand useFileControl a listing that is actually tagged for the
  // workspace path we're rendering right now.
  const safeLocalListing =
    localListing && localListing.forPath === workspacePath ? localListing : null;

  // What exists on disk, keyed by workspace-relative path — lets the Server
  // tab open/reveal a file's local copy only when there is one.
  const { localFileSet, localDirSet } = useMemo(() => {
    const files = new Set();
    const dirs = new Set();
    const walk = (nodes, prefix) => {
      for (const n of nodes ?? []) {
        const rel = prefix ? `${prefix}/${n.name}` : n.name;
        if (n.type === 'file') files.add(rel);
        else {
          dirs.add(rel);
          walk(n.children, rel);
        }
      }
    };
    walk(safeLocalListing?.children, '');
    return { localFileSet: files, localDirSet: dirs };
  }, [safeLocalListing]);

  useEffect(() => {
    loadWorkspace();
  }, [loadWorkspace]);

  const control = useFileControl({
    selection: activeSelection,
    rootId: tab === 'repo' ? repoRootId : wsRootId,
    workspacePath,
    localListing: safeLocalListing,
    reloadWorkspace: loadWorkspace,
  });

  // Absolute on-disk path for a tree node, or null when nothing is local.
  const diskPathFor = useCallback(
    (node) => {
      if (tab === 'workspace') {
        return node.isRoot ? workspacePath : node.id; // workspace ids ARE absolute paths
      }
      if (!workspacePath || !repoRootId) return null;
      if (node.isRoot) return workspacePath;
      const rel = node.id.slice(repoRootId.length);
      const present = node.type === 'file' ? localFileSet.has(rel) : localDirSet.has(rel);
      return present ? `${workspacePath}/${rel}` : null;
    },
    [tab, workspacePath, repoRootId, localFileSet, localDirSet]
  );

  // Double-click / Enter on a file: OS default app. Server tab silently
  // no-ops when there is no local copy (per spec).
  const handleOpenFile = useCallback(
    (node) => {
      if (!isTauri) return;
      const path = diskPathFor(node);
      if (path) openPath(path);
    },
    [diskPathFor]
  );

  const copyNodePath = useCallback(
    (node) => {
      const text =
        tab === 'repo'
          ? node.isRoot
            ? (projectName ?? '')
            : node.id.slice(repoRootId.length)
          : node.isRoot
            ? workspacePath
            : node.id;
      navigator.clipboard?.writeText(text).catch(() => {
        const ta = document.createElement('textarea');
        ta.value = text;
        document.body.appendChild(ta);
        ta.select();
        document.execCommand('copy');
        ta.remove();
      });
    },
    [tab, projectName, repoRootId, workspacePath]
  );

  // Bug F: refocusing the window refreshes so edits made outside the app (or a
  // teammate's submit) show up without a manual refresh. Debounced, and skipped
  // while a refresh/action is already running. Busy is read through a ref so the
  // listener doesn't re-subscribe on every busy flip.
  const refresh = control.actions.refresh;
  const busyRef = useRef(control.busy);
  busyRef.current = control.busy;
  useEffect(() => {
    let timer = null;
    const trigger = () => {
      clearTimeout(timer);
      timer = setTimeout(() => {
        if (!busyRef.current) refresh();
      }, 200);
    };
    const onVisible = () => {
      if (document.visibilityState === 'visible') trigger();
    };
    window.addEventListener('focus', trigger);
    document.addEventListener('visibilitychange', onVisible);
    return () => {
      clearTimeout(timer);
      window.removeEventListener('focus', trigger);
      document.removeEventListener('visibilitychange', onVisible);
    };
  }, [refresh]);

  const serverStatuses = useMemo(() => {
    const map = new Map();
    for (const [path, status] of control.statuses) {
      if (SERVER_TREE_KINDS.has(status.kind)) map.set(path, status);
    }
    return map;
  }, [control.statuses]);

  const workspaceDisplayTree = useMemo(
    () =>
      workspaceBrowser
        ? hideDeleted(workspaceBrowser.tree, control.deletedPaths, control.deletedFolders, wsRootId)
        : null,
    [workspaceBrowser, control.deletedPaths, control.deletedFolders, wsRootId]
  );

  useEffect(() => {
    setRepoSelection({
      selectedIds: repoBrowser?.selectedIds ?? new Set(),
      tree: repoBrowser?.tree ?? null,
      anchorId: repoBrowser?.anchorId ?? null,
    });
  }, [repoBrowser?.selectedIds, repoBrowser?.tree, repoBrowser?.anchorId, setRepoSelection]);

  useEffect(() => {
    // Selection logic sees the display tree (deleted paths hidden), matching
    // what the user can actually click.
    setWorkspaceSelection({
      selectedIds: workspaceBrowser?.selectedIds ?? new Set(),
      tree: workspaceDisplayTree,
      anchorId: workspaceBrowser?.anchorId ?? null,
    });
  }, [workspaceBrowser?.selectedIds, workspaceDisplayTree, workspaceBrowser?.anchorId, setWorkspaceSelection]);

  // Publish the hash maps the History-panel graph needs (it is a sibling panel
  // with no file-control hook). serverHashes normalizes serverFiles to
  // path -> head hash; syncedHashes is the synced manifest as-is.
  const serverHashes = useMemo(() => {
    const map = new Map();
    for (const [path, info] of control.serverFiles) map.set(path, info.hash ?? null);
    return map;
  }, [control.serverFiles]);

  // Tree fraction: your version / server version. Server tab forces synced ==
  // head (the depot is self-consistent -> n/n); workspace tab uses the real
  // synced manifest, so it can read e.g. 2/3 or 6/1.
  const versionFractionOf = useCallback(
    (relPath, mode) => {
      const history = fileHistoryIndex.get(relPath);
      if (!history) return null;
      const serverHead = control.serverFiles.get(relPath)?.hash ?? null;
      const synced = mode === 'repo' ? serverHead : control.baseHashes.get(relPath) ?? null;
      return versionFraction(history, serverHead, synced);
    },
    [fileHistoryIndex, control.serverFiles, control.baseHashes]
  );

  useEffect(() => {
    setFileHashes({ serverHashes, syncedHashes: control.baseHashes });
  }, [serverHashes, control.baseHashes, setFileHashes]);

  // The panel header (a sibling component with no file-control hook of its
  // own) shimmers "List Changes" while there is anything to submit.
  const hasPendingChanges =
    control.allSubmitItems.items.length > 0 || control.allSubmitItems.folders.length > 0;
  useEffect(() => {
    setHasPendingChanges(hasPendingChanges);
  }, [hasPendingChanges, setHasPendingChanges]);

  useEffect(() => {
    if (!highlightRequest?.paths?.length || !repoRootId) return;
    // Tree not loaded yet: leave the request pending so it applies once the
    // browser arrives. Once applied it is consumed — the provider outlives
    // this panel, and a stale request would re-expand and re-select the old
    // paths on every return to this page.
    if (!repoBrowser) return;

    setRepoBrowser((prev) => {
      if (!prev) return prev;
      const tree = structuredClone(prev.tree);
      const nodeIds = nodeIdsForRelativePaths(tree, repoRootId, highlightRequest.paths);
      if (!nodeIds.length) return prev;

      expandPathsToReveal(tree, nodeIds);
      return {
        ...prev,
        tree,
        selectedIds: new Set(nodeIds),
        anchorId: nodeIds[0],
      };
    });
    clearHighlightRequest();
  }, [highlightRequest, repoRootId, repoBrowser, clearHighlightRequest]);

  // Pulled changes flash for a moment — make sure they're on screen first.
  const { pulse } = control;
  useEffect(() => {
    if (!pulse?.size) return;
    const reveal = (rootId) => (prev) => {
      if (!prev || !rootId) return prev;
      const tree = structuredClone(prev.tree);
      const ids = nodeIdsForRelativePaths(tree, rootId, [...pulse]);
      if (!ids.length) return prev;
      expandPathsToReveal(tree, ids);
      return { ...prev, tree };
    };
    setWorkspaceBrowser(reveal(wsRootId));
    setRepoBrowser(reveal(repoRootId));
  }, [pulse, wsRootId, repoRootId]);

  // Select and expand the ancestors of a folder the user just created, once
  // the rescan that followed it has actually landed in workspaceBrowser.
  useEffect(() => {
    const pending = pendingRevealRef.current;
    if (!pending || !wsRootId || !workspaceBrowser) return;
    // The request was made for a different workspace (the user switched
    // folders before the rescan landed, or a stale request survived a
    // workspace change). That workspace's tree may happen to contain the same
    // relative path — drop the request rather than reveal the wrong folder.
    if (pending.workspacePath !== workspacePath) {
      pendingRevealRef.current = null;
      return;
    }
    const { rel } = pending;
    // Not in the tree yet — the rescan hasn't landed. Wait for the next change
    // rather than consuming the request against a stale tree.
    if (!nodeIdsForRelativePaths(workspaceBrowser.tree, wsRootId, [rel]).length) return;
    pendingRevealRef.current = null;
    setWorkspaceBrowser((prev) => {
      if (!prev) return prev;
      const tree = structuredClone(prev.tree);
      const ids = nodeIdsForRelativePaths(tree, wsRootId, [rel]);
      if (!ids.length) return prev;
      expandPathsToReveal(tree, ids);
      return { ...prev, tree, selectedIds: new Set(ids), anchorId: ids[0] };
    });
  }, [workspaceBrowser, wsRootId, workspacePath]);

  const chooseWorkspaceFolder = async () => {
    if (!activeProject) return;
    const folder = await selectWorkspaceFolder();
    if (folder) setWorkspacePath(folder);
  };

  // A transfer in flight logically disables every action (belt to the visual
  // overlay's suspenders — the busy flag alone proved lossy across focus and
  // selection changes).
  const transferring = Boolean(control.transfer);
  const toolbarEnabled = {
    refresh: control.enabled.refresh && !transferring,
    pull: control.enabled.pull && !transferring,
    submit: control.enabled.submit && !transferring,
    checkout: control.enabled.checkout && !transferring,
    add: control.enabled.addNew && !transferring,
    delete: control.enabled.delete && !transferring,
    revert: control.enabled.revert && !transferring,
  };

  // Progress strip over a blurred toolbar whenever a transfer or a workspace
  // rescan is running, so long operations never look like a hang. The
  // readout is AGGREGATE — four streams run at once, so naming "the" current
  // file just flickers between whichever stream reported last, and file
  // counts alone stall for minutes while big paks move gigabytes. Bytes are
  // the honest measure: percent + a rate-based time estimate. Counts appear
  // once the engine has reported its real (post-skip) total; the busy
  // fallback covers every remaining working phase (post-pull bookkeeping,
  // checkout syncs, …) — grey buttons must never appear without explanation.
  const refreshing = workspaceStatus.state === 'loading';
  const etaRef = useRef(null);
  useEffect(() => {
    const t = control.transfer;
    if (!t || !(t.bytesTotal > 0)) {
      etaRef.current = null;
      return;
    }
    if (!etaRef.current || etaRef.current.kind !== t.kind) {
      etaRef.current = { kind: t.kind, tracker: createEtaTracker() };
    }
    etaRef.current.tracker.update(t.bytesDone);
  }, [control.transfer]);

  let progressLabel = null;
  if (control.transfer) {
    const { kind, done, total, bytesDone = 0, bytesTotal = 0 } = control.transfer;
    const parts = [];
    if (total > 0) {
      const counts = `${done.toLocaleString()}/${total.toLocaleString()} files`;
      const pct = bytesTotal > 0 ? ` · ${Math.floor((bytesDone / bytesTotal) * 100)}%` : '';
      parts.push(`(${counts}${pct})`);
    }
    const eta = formatEta(
      etaRef.current?.kind === kind
        ? etaRef.current.tracker.remainingSeconds(bytesDone, bytesTotal)
        : null
    );
    if (eta) parts.push(`· ${eta} remaining`);
    progressLabel = `${kind === 'upload' ? 'Uploading' : 'Downloading'}${parts.length ? ` ${parts.join(' ')}` : ''}…`;
  } else if (refreshing) {
    progressLabel = 'Refreshing…';
  } else if (control.busy) {
    progressLabel = 'Working…';
  }

  // Single source of truth for the Add New / Don't Add flip — shared by the
  // toolbar button and the context-menu item so they cannot diverge.
  const addEntry =
    control.addMode === 'unadd'
      ? { label: "Don't Add", icon: 'assets/subtract.svg' }
      : { label: 'Add New', icon: 'assets/add.svg' };

  const toolbarItem = (item) => (item.id === 'add' ? { ...item, ...addEntry } : item);

  // Submit modal state: the changelist rows are snapshotted at open time so a
  // background refresh can't shuffle the list mid-review.
  const [submitSnapshot, setSubmitSnapshot] = useState(null);

  const openSubmit = useCallback(
    (scope) => {
      const source = scope === 'all' ? control.allSubmitItems : control.submitItems;
      if (!source.items.length && !source.folders.length) {
        control.setDialog({
          type: 'info',
          icon: 'assets/submit.svg',
          color: 'grey',
          title: 'Nothing to submit',
          text:
            'Your changelist has no pending changes' +
            (scope === 'all' ? '' : ' under the current selection') +
            '. Check out and edit files, mark new ones for add, or mark deletes first.',
        });
        return;
      }
      setSubmitSnapshot({ items: source.items, folders: source.folders });
    },
    [control]
  );

  // "List Changes" in the panel header reviews the entire changelist.
  const lastListSeq = useRef(0);
  useEffect(() => {
    if (!listChangesRequest || listChangesRequest.seq === lastListSeq.current) return;
    lastListSeq.current = listChangesRequest.seq;
    openSubmit('all');
    // Consume it: the provider outlives this panel, so a request left in
    // state re-fires on every remount (each fresh mount's seq guard starts
    // at 0) — returning to this page kept re-opening the submit modal or the
    // "Nothing to submit" popup.
    clearListChangesRequest();
  }, [listChangesRequest, openSubmit, clearListChangesRequest]);

  // Rollback requests from the History panel's revision modal.
  const lastRollbackSeq = useRef(0);
  useEffect(() => {
    if (!rollbackRequest || rollbackRequest.seq === lastRollbackSeq.current) return;
    lastRollbackSeq.current = rollbackRequest.seq;
    control.performRollback(rollbackRequest.paths, rollbackRequest.revision);
    // A rollback fires exactly once, when the user asks for it. Clear the
    // request from the app-lifetime provider now that it's dispatched, so
    // leaving and re-entering this page (which remounts RepoPanel and resets
    // the seq guard above) can't re-read a consumed request and re-fire the
    // rollback on its own. The seq guard still covers same-mount re-runs.
    clearRollbackRequest();
  }, [rollbackRequest, control.performRollback, clearRollbackRequest]);

  const handleToolbarClick = (id) => {
    if (!toolbarEnabled[id]) return;
    if (id === 'submit') {
      openSubmit('selection');
      return;
    }
    control.actions[id]?.();
  };

  // The right-click menu on either tree reuses the toolbar's own gating and
  // add/unadd flip verbatim — same source of truth, so a transfer in flight
  // can never be worked around via the context menu.
  const activeBrowser = tab === 'repo' ? repoBrowser : workspaceBrowser;
  const setActiveBrowser = tab === 'repo' ? setRepoBrowser : setWorkspaceBrowser;

  // The workspace's own folder name, for the dialog's "Create a folder in X."
  // The root node's *name* is the full path (fsTree.js builds it that way), so
  // it can't be used here. Splits on both separators for Windows paths.
  const workspaceFolderName =
    workspacePath.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || workspacePath;

  // Names already in a folder, so a collision is caught while typing rather
  // than only by the backend.
  const namesInFolder = (parentRel) => {
    const parent = parentRel
      ? findNodeById(workspaceBrowser?.tree, `${wsRootId}${parentRel}`)
      : workspaceBrowser?.tree?.[0];
    return new Set((parent?.children ?? []).map((child) => child.name));
  };

  const openNewFolder = (node) => {
    // No node = the empty-space menu, which always targets the root.
    const parentRel = !node || node.isRoot ? '' : node.id.slice(wsRootId.length);
    setNewFolder({ parentRel, parentLabel: parentRel ? node.name : workspaceFolderName });
  };

  const createFolder = async (name) => {
    const relPath = newFolder.parentRel ? `${newFolder.parentRel}/${name}` : name;
    const result = await createWorkspaceFolder({ folderPath: workspacePath, relPath });
    if (!result?.ok) return result ?? { ok: false, error: 'Could not create the folder.' };

    setNewFolder(null);
    // loadWorkspace is single-flight (see its body): if a scan is already in
    // flight it returns at once and queues a trailing rerun, so awaiting it
    // does NOT guarantee a fresh tree. Hand the reveal to the effect below,
    // which fires when workspaceBrowser actually changes. Tag it with the
    // workspace it was made for, so the effect can drop it if the user
    // switches to a different workspace before the rescan lands — otherwise
    // a same-named folder in the new workspace could be revealed instead.
    pendingRevealRef.current = { workspacePath, rel: relPath };
    loadWorkspace();
    return { ok: true };
  };

  const buildRowMenuItems = (node) => {
    const single = (activeBrowser?.selectedIds.size ?? 0) === 1;
    const revealPath = single ? diskPathFor(node) : null;
    const items = [
      { key: 'pull', label: 'Pull Latest', icon: 'assets/pull.svg', disabled: !toolbarEnabled.pull, onClick: control.actions.pull },
      { key: 'submit', label: 'Submit', icon: 'assets/submit.svg', disabled: !toolbarEnabled.submit, onClick: () => openSubmit('selection') },
      { key: 'checkout', label: 'Checkout', icon: 'assets/checkout.svg', disabled: !toolbarEnabled.checkout, onClick: control.actions.checkout },
      // Same flip as the toolbar button — both read addEntry.
      { key: 'add', ...addEntry, disabled: !toolbarEnabled.add, onClick: control.actions.add },
      { key: 'delete', label: 'Delete', icon: 'assets/delete.svg', disabled: !toolbarEnabled.delete, onClick: control.actions.delete },
      { key: 'revert', label: 'Revert', icon: 'assets/revert.svg', disabled: !toolbarEnabled.revert, onClick: control.actions.revert },
      { key: 'sep1', separator: true },
      {
        key: 'forceCheckin',
        label: 'Force Check-In…',
        icon: 'assets/checkout.svg',
        disabled: !control.enabled.forceCheckin || transferring,
        onClick: control.actions.forceCheckin,
      },
      { key: 'sep2', separator: true },
    ];
    if (isTauri) {
      items.push({
        key: 'reveal',
        label: fileManagerLabel,
        icon: 'assets/folder.svg',
        disabled: !revealPath,
        onClick: () => revealInFileManager(revealPath),
      });
    }
    items.push({
      key: 'copyPath',
      label: 'Copy Path',
      disabled: !single,
      onClick: () => copyNodePath(node),
    });
    if (node.type === 'folder') {
      items.push({ key: 'sep3', separator: true });
      // Workspace only: the Server tree has no empty directories to create.
      if (tab === 'workspace' && isTauri && workspacePath) {
        items.push({
          key: 'newFolder',
          label: 'New Folder…',
          icon: 'assets/circle_add.svg',
          disabled: !single || transferring,
          onClick: () => openNewFolder(node),
        });
      }
      items.push(
        {
          key: 'expandAll',
          label: 'Show All Children',
          disabled: !single,
          onClick: () =>
            setActiveBrowser((prev) =>
              prev ? { ...prev, tree: setSubtreeExpanded(prev.tree, node.id, true) } : prev
            ),
        },
        {
          key: 'collapseAll',
          label: 'Collapse All Children',
          disabled: !single,
          onClick: () =>
            setActiveBrowser((prev) =>
              prev ? { ...prev, tree: setSubtreeExpanded(prev.tree, node.id, false) } : prev
            ),
        }
      );
    }
    return items;
  };

  const emptyMenuItems = (selectAll) => {
    const rootId = tab === 'repo' ? repoRootId : wsRootId;
    const setExpanded = (expanded) => () =>
      setActiveBrowser((prev) =>
        prev ? { ...prev, tree: setSubtreeExpanded(prev.tree, rootId, expanded) } : prev
      );
    return buildEmptyMenuItems({
      tab,
      isTauri,
      hasWorkspace: Boolean(workspacePath),
      fileManagerLabel,
      canRefresh: toolbarEnabled.refresh,
      canNewFolder: !transferring,
      onNewFolder: () => openNewFolder(null),
      onReveal: () => revealInFileManager(workspacePath),
      // The container is the tree's root: the project name on the Server tab,
      // the workspace path on the Workspace tab. copyNodePath already branches
      // on isRoot, so a bare root marker is all it needs.
      onCopyPath: () => copyNodePath({ isRoot: true }),
      onSelectAll: selectAll,
      onExpandAll: setExpanded(true),
      onCollapseAll: setExpanded(false),
      onRefresh: control.actions.refresh,
    });
  };

  // Native OS drag-and-drop into the Workspace tree. The handler runs outside
  // React's lifecycle, so it reads everything through this ref.
  const dropCtxRef = useRef({});
  dropCtxRef.current = {
    active: tab === 'workspace' && isTauri && Boolean(workspacePath) && !transferring,
    wsRootId,
    localDirSet,
    importDropped: null, // set just below, once importDropped exists
  };

  const dropTargetAt = (x, y) => {
    const ctx = dropCtxRef.current;
    if (!ctx.active || x == null) return null;
    const el = document.elementFromPoint(x, y);
    const container = el?.closest('.file-tree-container');
    if (!container) return null;
    const row = el?.closest('[data-node-id]');
    if (!row) return { id: ctx.wsRootId, rel: '' }; // empty tree space = root
    const id = row.dataset.nodeId;
    let rel = id.startsWith(ctx.wsRootId) ? id.slice(ctx.wsRootId.length) : '';
    // Files take the drop for their parent folder, like Finder.
    if (rel && !ctx.localDirSet.has(rel)) rel = rel.split('/').slice(0, -1).join('/');
    return { id: rel ? ctx.wsRootId + rel : ctx.wsRootId, rel };
  };

  const importDropped = useCallback(
    async (sources, destRel) => {
      const base = { folderPath: workspacePath, destRel, sources };
      const probe = await importWorkspacePaths({ ...base, probe: true, overwrite: false });
      const run = async (overwrite) => {
        const result = await importWorkspacePaths({ ...base, probe: false, overwrite });
        if (result.errors?.length) {
          control.setDialog({
            type: 'filelist',
            variant: 'warning',
            icon: 'assets/circle_exclaim.svg',
            color: 'orange',
            title: 'Some items could not be copied',
            text: 'These dropped items failed to copy into the workspace:',
            rows: result.errors.slice(0, 50).map((e) => ({ path: e.path, note: e.error })),
            columnLabel: 'Problem',
            actions: [{ label: 'Okay', action: () => control.setDialog(null) }],
          });
        }
        await loadWorkspace();
      };
      if (probe.collisions?.length) {
        control.setDialog({
          type: 'choice',
          icon: 'assets/circle_exclaim.svg',
          color: 'orange',
          title: 'Replace existing items?',
          text:
            `${probe.collisions.length} dropped item(s) already exist here: ` +
            `${probe.collisions.slice(0, 5).join(', ')}${probe.collisions.length > 5 ? ', …' : ''}.`,
          options: [
            { label: 'Replace', action: () => { control.setDialog(null); run(true); } },
            { label: 'Skip Existing', action: () => { control.setDialog(null); run(false); } },
            { label: 'Cancel', action: () => control.setDialog(null) },
          ],
        });
        return;
      }
      run(false);
    },
    [workspacePath, control, loadWorkspace]
  );
  dropCtxRef.current.importDropped = importDropped;

  useEffect(() => {
    if (!isTauri) return undefined;
    let unlisten = null;
    let disposed = false;
    onFileDrop((event) => {
      if (event.type === 'leave') {
        setDropTarget(null);
        return;
      }
      const target = dropTargetAt(event.x, event.y);
      if (event.type === 'enter' || event.type === 'over') {
        setDropTarget(target);
        return;
      }
      // drop
      setDropTarget(null);
      if (target && event.paths?.length) {
        dropCtxRef.current.importDropped?.(event.paths, target.rel);
      }
    }).then((fn) => {
      if (disposed) fn();
      else unlisten = fn;
    });
    return () => {
      disposed = true;
      unlisten?.();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const treeProps = (browser, setBrowser, mode, visible, rootId, displayNodes = null) => ({
    // displayNodes is a filtered view; state updates (expansion toggles) still
    // apply to the full stored tree so hidden nodes survive in browser state.
    nodes: displayNodes ?? browser.tree,
    setNodes: (updater) =>
      setBrowser((prev) => ({
        ...prev,
        tree: typeof updater === 'function' ? updater(prev.tree) : updater,
      })),
    mode,
    selectedIds: browser.selectedIds,
    anchorId: browser.anchorId,
    onSelect: (selectedIds, anchorId) =>
      setBrowser((prev) => ({ ...prev, selectedIds, anchorId })),
    sort: browser.sort,
    onSort: (sort) => setBrowser((prev) => ({ ...prev, sort })),
    scrollTop: browser.scrollTop,
    onScroll: (scrollTop) => setBrowser((prev) => ({ ...prev, scrollTop })),
    visible,
    rootId,
    // Server tab: server truth + this client's delete marks; Workspace tab:
    // the full local picture (delete-marked files have no rows there anyway).
    statuses: mode === 'repo' ? serverStatuses : control.statuses,
    onStatusClick: (status) => control.showStatusInfo(status),
    pulsePaths: control.pulse,
    deletedFolders: mode === 'repo' ? control.deletedFolders : null,
    onContextMenuRow: (e, node) => setMenu({ x: e.clientX, y: e.clientY, node }),
    onContextMenuEmpty: (e, { selectAll }) =>
      setMenu({ x: e.clientX, y: e.clientY, empty: true, selectAll }),
    onOpenFile: handleOpenFile,
    dropTargetId: mode === 'workspace' ? (dropTarget?.id ?? null) : null,
    versionFractionOf: (relPath) => versionFractionOf(relPath, mode),
  });

  const address =
    tab === 'repo'
      ? projectName ?? 'No project selected'
      : workspacePath || 'Select a workspace folder';

  const repoBody = () => {
    if (depotError) {
      return (
        <TreeNotice>
          <p>{depotError}</p>
          <button type="button" className="workspace-tree-action" onClick={refreshDepotTree}>
            <img src="assets/refresh.svg" alt="" />
            Retry
          </button>
        </TreeNotice>
      );
    }
    if (!repoBrowser) return <TreeNotice>Loading server file tree…</TreeNotice>;
    return <FileTree {...treeProps(repoBrowser, setRepoBrowser, 'repo', true, repoRootId)} />;
  };

  const workspaceBody = () => {
    if (!workspacePath) {
      return (
        <TreeNotice>
          <p>No workspace folder is set for this server.</p>
          {isTauri ? (
            <button type="button" className="workspace-tree-action" onClick={chooseWorkspaceFolder}>
              <img src="assets/folder.svg" alt="" />
              Choose Workspace Folder…
            </button>
          ) : (
            <p className="workspace-tree-subnote">
              Set one from the Connections window (desktop app only).
            </p>
          )}
        </TreeNotice>
      );
    }
    if (workspaceStatus.state === 'loading' && !workspaceBrowser) {
      return <TreeNotice>Reading {workspacePath}…</TreeNotice>;
    }
    if (workspaceStatus.state === 'error') {
      return (
        <TreeNotice>
          <p>{workspaceStatus.error}</p>
          <button type="button" className="workspace-tree-action" onClick={loadWorkspace}>
            <img src="assets/refresh.svg" alt="" />
            Retry
          </button>
        </TreeNotice>
      );
    }
    if (!workspaceBrowser) return null;
    return (
      <FileTree
        {...treeProps(workspaceBrowser, setWorkspaceBrowser, 'workspace', true, wsRootId, workspaceDisplayTree)}
      />
    );
  };

  return (
    <>
      <div className="panel-tabs">
        <button
          type="button"
          className={`panel-tab${tab === 'repo' ? ' panel-tab--active' : ''}`}
          onClick={() => setTab('repo')}
        >
          <img src="assets/repo.svg" alt="" className="tab-icon tab-icon--repo" />
          Server
        </button>
        <button
          type="button"
          className={`panel-tab${tab === 'workspace' ? ' panel-tab--active' : ''}`}
          onClick={() => setTab('workspace')}
        >
          <img src="assets/workspace.svg" alt="" className="tab-icon tab-icon--workspace" />
          Workspace
        </button>
      </div>
      <div className="path-bar">
        <input type="text" readOnly value={address} />
        {tab === 'workspace' && isTauri && (
          <button
            type="button"
            className="path-bar-btn"
            title="Choose workspace folder"
            aria-label="Choose workspace folder"
            onClick={chooseWorkspaceFolder}
          >
            <img src="assets/folder.svg" alt="" />
          </button>
        )}
        {/* Server tab's mirror of the folder picker: the globe opens the
            project picker (ConnectionsModal — "Which project would you like
            to work on today?"). Not gated on isTauri, unlike the folder
            picker: that modal works in the browser build too. */}
        {tab === 'repo' && (
          <button
            type="button"
            className="path-bar-btn"
            title="Choose project"
            aria-label="Choose project"
            onClick={() => openModal('connections')}
          >
            <img src="assets/globe.svg" alt="" />
          </button>
        )}
      </div>
      <div className="toolbar-wrap">
        <div className={`toolbar${progressLabel ? ' toolbar--busy' : ''}`}>
          {TOOLBAR.map((entry) => {
            const { id, label, icon } = toolbarItem(entry);
            return (
              <button
                key={id}
                type="button"
                className={`toolbar-btn${toolbarEnabled[id] ? '' : ' toolbar-btn--disabled'}`}
                aria-disabled={!toolbarEnabled[id]}
                onClick={() => handleToolbarClick(id)}
              >
                <img src={icon} alt="" />
                <span>{label}</span>
              </button>
            );
          })}
        </div>
        {progressLabel && (
          <div className="toolbar-progress" role="status">
            <span className="toolbar-progress-spinner" aria-hidden="true" />
            <span className="toolbar-progress-label">{progressLabel}</span>
          </div>
        )}
      </div>
      {tab === 'repo' && repoBody()}
      {tab === 'workspace' && workspaceBody()}

      {menu && (
        <TreeContextMenu
          x={menu.x}
          y={menu.y}
          items={menu.empty ? emptyMenuItems(menu.selectAll) : buildRowMenuItems(menu.node)}
          onClose={() => setMenu(null)}
        />
      )}

      {newFolder && (
        <PromptDialog
          icon="assets/folder.svg"
          title="New Folder"
          text={`Create a folder in ${newFolder.parentLabel}.`}
          label="Folder name"
          confirmLabel="Create"
          validate={(value) => folderNameError(value, namesInFolder(newFolder.parentRel))}
          onSubmit={createFolder}
          onCancel={() => setNewFolder(null)}
        />
      )}

      {control.dialog?.type === 'info' && (
        <InfoDialog
          icon={control.dialog.icon}
          color={control.dialog.color}
          title={control.dialog.title}
          text={control.dialog.text}
          variant={control.dialog.variant ?? 'info'}
          onClose={() => control.setDialog(null)}
        />
      )}
      {control.dialog?.type === 'confirm' && (
        <DangerConfirmDialog
          title={control.dialog.title}
          text={control.dialog.text}
          onCancel={() => control.setDialog(null)}
          onConfirm={control.dialog.onConfirm}
        />
      )}
      {control.dialog?.type === 'choice' && (
        <ChoiceDialog
          icon={control.dialog.icon}
          color={control.dialog.color}
          title={control.dialog.title}
          text={control.dialog.text}
          options={control.dialog.options}
          onDismiss={() => control.setDialog(null)}
        />
      )}
      {control.dialog?.type === 'filelist' && (
        <FileListDialog
          variant={control.dialog.variant ?? 'info'}
          icon={control.dialog.icon}
          color={control.dialog.color}
          title={control.dialog.title}
          text={control.dialog.text}
          rows={control.dialog.rows}
          columnLabel={control.dialog.columnLabel}
          actions={control.dialog.actions}
          onDismiss={() => control.setDialog(null)}
        />
      )}
      {submitSnapshot && (
        <SubmitModal
          items={submitSnapshot.items}
          folders={submitSnapshot.folders}
          nextRevision={control.nextRevision}
          busy={control.busy}
          onClose={() => setSubmitSnapshot(null)}
          onSubmit={async (files, folders, description) => {
            const result = await control.performSubmit(files, folders, description);
            if (result.ok || result.blocked) setSubmitSnapshot(null);
          }}
        />
      )}
    </>
  );
}
