{
  "name": "file-tabs",
  "dependencies": [],
  "registryDependencies": [
    "file-icon"
  ],
  "files": [
    {
      "path": "file-tabs.tsx",
      "content": "\"use client\";\r\n\r\nimport { css, themeVars as theme } from \"@yugnex/core\";\r\nimport { useEffect, useRef, type KeyboardEvent, type MouseEvent } from \"react\";\r\nimport { FileIcon, type FileStatus } from \"./file-icon\";\r\n\r\nexport interface OpenFile {\r\n  /** Full path — the tab's identity. */\r\n  path: string;\r\n  /** Overrides the label derived from `path`. */\r\n  label?: string;\r\n  /** Unsaved changes: shows a dot in place of the close button until hovered. */\r\n  dirty?: boolean;\r\n  status?: FileStatus;\r\n  /** Preview tabs render italic and are replaced by the next preview open. */\r\n  preview?: boolean;\r\n}\r\n\r\nconst stripClass = css({\r\n  display: \"flex\",\r\n  alignItems: \"stretch\",\r\n  gap: \"1px\",\r\n  overflowX: \"auto\",\r\n  backgroundColor: theme.color.muted,\r\n  borderBottom: `1px solid ${theme.color.border}`,\r\n  fontFamily: theme.fontFamily.sans,\r\n  fontSize: theme.fontSize.sm,\r\n  // A tab strip that shows a scrollbar under the tabs looks broken; the\r\n  // overflow still scrolls by wheel, drag, and keyboard.\r\n  scrollbarWidth: \"none\",\r\n  \"&::-webkit-scrollbar\": { display: \"none\" },\r\n});\r\n\r\nconst tabClass = css({\r\n  position: \"relative\",\r\n  display: \"inline-flex\",\r\n  alignItems: \"center\",\r\n  gap: theme.space[1.5],\r\n  flexShrink: 0,\r\n  maxWidth: \"14rem\",\r\n  padding: `${theme.space[2]} ${theme.space[2]} ${theme.space[2]} ${theme.space[3]}`,\r\n  border: \"none\",\r\n  borderTop: \"2px solid transparent\",\r\n  backgroundColor: \"transparent\",\r\n  color: theme.color.mutedForeground,\r\n  font: \"inherit\",\r\n  cursor: \"pointer\",\r\n  transitionProperty: \"background-color, color\",\r\n  transitionDuration: theme.duration.fast,\r\n  \"&:hover\": { backgroundColor: theme.color.background, color: theme.color.foreground },\r\n  \"&:focus-visible\": { outline: `2px solid ${theme.color.ring}`, outlineOffset: \"-2px\" },\r\n  '&[aria-selected=\"true\"]': {\r\n    backgroundColor: theme.color.background,\r\n    color: theme.color.foreground,\r\n    borderTopColor: theme.color.primary,\r\n  },\r\n});\r\n\r\nconst previewTabClass = css({ fontStyle: \"italic\" });\r\n\r\nconst labelClass = css({\r\n  overflow: \"hidden\",\r\n  textOverflow: \"ellipsis\",\r\n  whiteSpace: \"nowrap\",\r\n  minWidth: 0,\r\n});\r\n\r\nconst deletedLabelClass = css({ textDecoration: \"line-through\", opacity: 0.7 });\r\n\r\n/**\r\n * The close button and the dirty dot occupy the same slot: the dot is the\r\n * resting state for an unsaved file and swaps to the ✕ on hover/focus, which\r\n * is how every editor handles it — a permanent ✕ next to a permanent dot\r\n * reads as two separate controls.\r\n */\r\nconst closeSlotClass = css({\r\n  display: \"inline-flex\",\r\n  alignItems: \"center\",\r\n  justifyContent: \"center\",\r\n  flexShrink: 0,\r\n  width: \"1.125rem\",\r\n  height: \"1.125rem\",\r\n  marginLeft: theme.space[0.5],\r\n  borderRadius: theme.radius.sm,\r\n  border: \"none\",\r\n  background: \"transparent\",\r\n  color: \"inherit\",\r\n  cursor: \"pointer\",\r\n  padding: 0,\r\n  \"&:hover\": { backgroundColor: theme.color.border },\r\n  \"&:focus-visible\": { outline: `2px solid ${theme.color.ring}`, outlineOffset: \"1px\" },\r\n});\r\n\r\nconst dirtyDotClass = css({\r\n  width: \"8px\",\r\n  height: \"8px\",\r\n  borderRadius: \"9999px\",\r\n  backgroundColor: \"currentColor\",\r\n  '[data-dirty=\"true\"]:not(:hover) &': { display: \"block\" },\r\n});\r\n\r\nconst closeIconClass = css({\r\n  display: \"none\",\r\n  '[data-dirty=\"false\"] &, [data-dirty=\"true\"]:hover &, [data-dirty=\"true\"]:focus-within &': {\r\n    display: \"block\",\r\n  },\r\n});\r\n\r\nconst dirtyDotHideOnHoverClass = css({\r\n  '[data-dirty=\"true\"]:hover &, [data-dirty=\"true\"]:focus-within &': { display: \"none\" },\r\n});\r\n\r\nconst statusBarClass = css({\r\n  position: \"absolute\",\r\n  left: 0,\r\n  right: 0,\r\n  bottom: 0,\r\n  height: \"2px\",\r\n});\r\n\r\nconst STATUS_COLOR: Record<Exclude<FileStatus, \"unchanged\">, string> = {\r\n  new: theme.color.success,\r\n  modified: theme.color.warning,\r\n  deleted: theme.color.destructive,\r\n};\r\n\r\nexport interface FileTabsProps {\r\n  files: OpenFile[];\r\n  /** Path of the active tab. */\r\n  active?: string;\r\n  onActivate?: (path: string) => void;\r\n  onClose?: (path: string) => void;\r\n  /** Accessible name for the strip. */\r\n  label?: string;\r\n  className?: string;\r\n}\r\n\r\n/**\r\n * The strip of open files above an editor pane.\r\n *\r\n * Distinct from `tabs`, which is a content switcher: these carry a file\r\n * identity (icon, dirty state, changeset status), close individually, and\r\n * are expected to come and go as an agent opens files — so the tab list is\r\n * data, not markup.\r\n */\r\nexport function FileTabs({ files, active, onActivate, onClose, label = \"Open files\", className }: FileTabsProps) {\r\n  const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());\r\n  const stripRef = useRef<HTMLDivElement | null>(null);\r\n\r\n  // Keep the active tab on screen — an agent switching files faster than the\r\n  // user can scroll would otherwise leave the highlighted tab out of view.\r\n  useEffect(() => {\r\n    if (!active) return;\r\n    tabRefs.current.get(active)?.scrollIntoView({ block: \"nearest\", inline: \"nearest\" });\r\n  }, [active]);\r\n\r\n  const focusByOffset = (from: string, offset: number) => {\r\n    const index = files.findIndex((file) => file.path === from);\r\n    if (index === -1) return;\r\n    const next = files[(index + offset + files.length) % files.length];\r\n    if (next) tabRefs.current.get(next.path)?.focus();\r\n  };\r\n\r\n  const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, file: OpenFile) => {\r\n    switch (event.key) {\r\n      case \"ArrowRight\":\r\n        event.preventDefault();\r\n        focusByOffset(file.path, 1);\r\n        break;\r\n      case \"ArrowLeft\":\r\n        event.preventDefault();\r\n        focusByOffset(file.path, -1);\r\n        break;\r\n      case \"Home\":\r\n        event.preventDefault();\r\n        if (files[0]) tabRefs.current.get(files[0].path)?.focus();\r\n        break;\r\n      case \"End\":\r\n        event.preventDefault();\r\n        if (files[files.length - 1]) tabRefs.current.get(files[files.length - 1]!.path)?.focus();\r\n        break;\r\n      // Editors close the focused tab on Delete/Backspace; do the same so the\r\n      // strip is operable without reaching for the ✕.\r\n      case \"Delete\":\r\n      case \"Backspace\":\r\n        event.preventDefault();\r\n        onClose?.(file.path);\r\n        break;\r\n      default:\r\n        break;\r\n    }\r\n  };\r\n\r\n  const closeTab = (event: MouseEvent, path: string) => {\r\n    // Without this the click also activates the tab being removed.\r\n    event.stopPropagation();\r\n    onClose?.(path);\r\n  };\r\n\r\n  return (\r\n    <div\r\n      role=\"tablist\"\r\n      aria-label={label}\r\n      ref={stripRef}\r\n      className={className ? `${stripClass} ${className}` : stripClass}\r\n    >\r\n      {files.map((file) => {\r\n        const isActive = file.path === active;\r\n        const name = file.label ?? (file.path.split(\"/\").pop() ?? file.path);\r\n        const dirty = Boolean(file.dirty);\r\n        const status = file.status && file.status !== \"unchanged\" ? file.status : undefined;\r\n\r\n        return (\r\n          <button\r\n            key={file.path}\r\n            type=\"button\"\r\n            role=\"tab\"\r\n            ref={(el) => {\r\n              if (el) tabRefs.current.set(file.path, el);\r\n              else tabRefs.current.delete(file.path);\r\n            }}\r\n            aria-selected={isActive}\r\n            // Roving tabindex: the whole strip is one tab stop.\r\n            tabIndex={isActive ? 0 : -1}\r\n            data-dirty={dirty}\r\n            title={file.path}\r\n            className={file.preview ? `${tabClass} ${previewTabClass}` : tabClass}\r\n            onClick={() => onActivate?.(file.path)}\r\n            onKeyDown={(event) => onKeyDown(event, file)}\r\n          >\r\n            <FileIcon filename={file.path} size={14} />\r\n\r\n            <span className={status === \"deleted\" ? `${labelClass} ${deletedLabelClass}` : labelClass}>\r\n              {name}\r\n            </span>\r\n\r\n            {onClose ? (\r\n              <span\r\n                // A button inside a button is invalid HTML, so the close\r\n                // affordance is a span with an explicit role — it still gets\r\n                // keyboard coverage through the tab's Delete/Backspace\r\n                // handler above.\r\n                role=\"button\"\r\n                tabIndex={-1}\r\n                aria-label={`Close ${name}`}\r\n                className={closeSlotClass}\r\n                onClick={(event) => closeTab(event, file.path)}\r\n              >\r\n                {dirty ? (\r\n                  <span className={`${dirtyDotClass} ${dirtyDotHideOnHoverClass}`} aria-hidden=\"true\" />\r\n                ) : null}\r\n                <svg className={closeIconClass} width=\"10\" height=\"10\" viewBox=\"0 0 10 10\" fill=\"none\" aria-hidden=\"true\">\r\n                  <path d=\"M2 2l6 6M8 2l-6 6\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />\r\n                </svg>\r\n              </span>\r\n            ) : null}\r\n\r\n            {status ? (\r\n              <span\r\n                className={statusBarClass}\r\n                style={{ backgroundColor: STATUS_COLOR[status] }}\r\n                aria-hidden=\"true\"\r\n              />\r\n            ) : null}\r\n          </button>\r\n        );\r\n      })}\r\n    </div>\r\n  );\r\n}\r\n",
      "type": "registry:component"
    }
  ]
}