Files
2026-07-01 17:56:31 +08:00

379 lines
13 KiB
TypeScript

/**
* OrgVersionSwitcher — editor-toolbar "version picker" for the org.
*
* Conceptually equivalent to Figma's page switcher or VS Code's git-branch
* indicator: a compact pill showing the active saved-org name (+ modified
* indicator when the editor has changed since the last loaded snapshot),
* plus a glass popover command-menu for search / load / save-as-copy
* / delete.
*
* All visual tokens match the house dialect (refined-technical, dark, no
* emoji, no purple gradients). Styles live in structure.css under `.sos-*`.
*
* Lives in the StructureEditor toolbar (Team tab, org mode only).
*/
import { useCallback, useEffect, useRef, useState } from 'react'
interface SavedOrg {
name: string
organization_name?: string
saved_at: number
roles_count: number
employees_count: number
}
export interface OrgVersionSwitcherProps {
savedOrgs: SavedOrg[] | null
activeName: string | null
isDirty: boolean
onRefresh: () => void
onSaveAs: (name: string, overwrite: boolean) => void
onLoad: (name: string) => void
onDelete: (name: string) => void
}
const MAX_DISPLAY_NAME = 80
function formatRelativeTime(epochSeconds: number): string {
const now = Date.now() / 1000
const diff = Math.max(0, now - epochSeconds)
if (diff < 60) return 'just now'
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`
try {
return new Date(epochSeconds * 1000).toLocaleDateString()
} catch {
return ''
}
}
function displayOrgName(org: SavedOrg): string {
return (org.organization_name || org.name).trim() || org.name
}
function isValidDisplayName(value: string): boolean {
const trimmed = value.trim()
return trimmed.length > 0
&& trimmed.length <= MAX_DISPLAY_NAME
&& !/[\\/]/.test(trimmed)
&& !/[\u0000-\u001f]/.test(trimmed)
}
function slugifyOrgDisplayName(value: string): string {
const ascii = value.normalize('NFKD').replace(/[^\x00-\x7F]/g, '')
const slug = ascii
.toLowerCase()
.trim()
.replace(/\s+/g, '_')
.replace(/[^a-z0-9_-]+/g, '_')
.replace(/_+/g, '_')
.replace(/^[_-]+|[_-]+$/g, '')
return slug.slice(0, 64) || 'org'
}
/* Inline SVG glyphs — match the house convention (no emoji, no font icons). */
function LayersGlyph({ className }: { className?: string }) {
return (
<svg className={className} width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden>
<path d="M8 1.5L1.5 4.5L8 7.5L14.5 4.5L8 1.5Z" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" />
<path d="M2 8L8 10.8L14 8" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" opacity="0.6" />
<path d="M2 11.5L8 14.2L14 11.5" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" opacity="0.35" />
</svg>
)
}
function Caret({ className }: { className?: string }) {
return (
<svg className={className} width="10" height="10" viewBox="0 0 12 12" fill="none" aria-hidden>
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
function SearchGlyph({ className }: { className?: string }) {
return (
<svg className={className} width="11" height="11" viewBox="0 0 12 12" fill="none" aria-hidden>
<circle cx="5" cy="5" r="3.2" stroke="currentColor" strokeWidth="1.3" />
<path d="M7.5 7.5L10 10" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
</svg>
)
}
export function OrgVersionSwitcher({
savedOrgs, activeName, isDirty, onRefresh, onSaveAs, onLoad, onDelete,
}: OrgVersionSwitcherProps) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const [highlighted, setHighlighted] = useState(0)
const [saveAsMode, setSaveAsMode] = useState(false)
const [saveAsName, setSaveAsName] = useState('')
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
const [loadingName, setLoadingName] = useState<string | null>(null)
const wrapperRef = useRef<HTMLDivElement>(null)
const saveInputRef = useRef<HTMLInputElement>(null)
// Clear loading state once the list or activeName updates (proxy for ack).
useEffect(() => { setLoadingName(null) }, [activeName, savedOrgs])
// Filter list by search.
const filtered = (savedOrgs ?? []).filter(o =>
!search.trim() || o.name.toLowerCase().includes(search.trim().toLowerCase()),
)
// Refresh once on first open.
const firstOpenRef = useRef(false)
useEffect(() => {
if (open && !firstOpenRef.current) {
firstOpenRef.current = true
onRefresh()
}
}, [open, onRefresh])
// Auto-focus save input when entering save-as mode.
useEffect(() => {
if (saveAsMode) saveInputRef.current?.focus()
}, [saveAsMode])
// Clamp highlight when filtered list shrinks.
useEffect(() => {
setHighlighted(h => Math.min(h, Math.max(0, filtered.length - 1)))
}, [filtered.length])
// Close on outside click.
useEffect(() => {
if (!open) return
const onDocClick = (e: MouseEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
closePopover()
}
}
document.addEventListener('mousedown', onDocClick)
return () => document.removeEventListener('mousedown', onDocClick)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
const closePopover = useCallback(() => {
setOpen(false)
setSearch('')
setHighlighted(0)
setSaveAsMode(false)
setSaveAsName('')
setConfirmDelete(null)
}, [])
const handleLoad = useCallback((name: string) => {
if (name === activeName) {
// Already active — no need to round-trip; just close the popover.
closePopover()
return
}
setLoadingName(name)
onLoad(name)
closePopover()
}, [onLoad, closePopover, activeName])
const handleDelete = useCallback((name: string) => {
onDelete(name)
setConfirmDelete(null)
}, [onDelete])
const saveAsTrimmed = saveAsName.trim()
const saveAsValid = isValidDisplayName(saveAsTrimmed)
const saveAsSlug = slugifyOrgDisplayName(saveAsTrimmed)
const saveAsExists = (savedOrgs ?? []).some(o =>
o.name === saveAsSlug || displayOrgName(o).toLowerCase() === saveAsTrimmed.toLowerCase(),
)
const handleSaveAs = useCallback(() => {
if (!saveAsValid) return
onSaveAs(saveAsTrimmed, saveAsExists)
setSaveAsMode(false)
setSaveAsName('')
closePopover()
}, [saveAsValid, saveAsTrimmed, saveAsExists, onSaveAs, closePopover])
// Keyboard navigation inside the popover.
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
closePopover()
return
}
if (saveAsMode) {
if (e.key === 'Enter') {
e.preventDefault()
handleSaveAs()
}
return
}
if (e.key === 'ArrowDown') {
e.preventDefault()
setHighlighted(h => Math.min(h + 1, Math.max(0, filtered.length - 1)))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setHighlighted(h => Math.max(0, h - 1))
} else if (e.key === 'Enter') {
const target = filtered[highlighted]
if (target) handleLoad(target.name)
} else if ((e.key === 'Backspace' || e.key === 'Delete') && (e.metaKey || e.ctrlKey)) {
const target = filtered[highlighted]
if (target) {
e.preventDefault()
setConfirmDelete(target.name)
}
}
}
return (
<div className="sos-wrap" ref={wrapperRef}>
<button
type="button"
className="sos-pill"
data-open={open ? 'true' : 'false'}
onClick={() => setOpen(o => !o)}
aria-haspopup="listbox"
aria-expanded={open}
title={activeName ? `Active saved architecture: ${activeName}` : 'No saved architecture loaded'}
>
<LayersGlyph className="sos-pill-glyph" />
{activeName ? (
<span className="sos-pill-name">{activeName}</span>
) : (
<span className="sos-pill-name sos-pill-name--placeholder">draft</span>
)}
{isDirty && <span className="sos-pill-dirty" aria-label="Modified since opened" />}
<Caret className="sos-pill-caret" />
</button>
{open && (
<div className="sos-popover" role="listbox" onKeyDown={onKeyDown} tabIndex={-1}>
<div className="sos-search-row">
<SearchGlyph className="sos-search-glyph" />
<input
type="text"
className="sos-search-input"
placeholder="Search architectures…"
value={search}
onChange={e => { setSearch(e.target.value); setHighlighted(0) }}
autoFocus
onKeyDown={onKeyDown}
/>
<kbd className="sos-search-hint">↑↓ </kbd>
</div>
<div className="sos-list">
{savedOrgs === null ? (
<div className="sos-empty">Loading</div>
) : filtered.length === 0 ? (
<div className="sos-empty">
{(savedOrgs ?? []).length === 0 ? 'No saved architectures.' : 'No matches.'}
</div>
) : (
filtered.map((org, idx) => {
const isActive = activeName === org.name
const isHighlighted = highlighted === idx
const title = displayOrgName(org)
return (
<div
key={org.name}
className={`sos-row${isActive ? ' sos-row--active' : ''}`}
data-highlighted={isHighlighted ? 'true' : 'false'}
onMouseEnter={() => setHighlighted(idx)}
onClick={() => handleLoad(org.name)}
role="option"
aria-selected={isActive}
>
<div className="sos-row-meta">
<span className="sos-row-name">{title}</span>
<span className="sos-row-stats">
{title !== org.name && `${org.name} · `}
{org.roles_count} {org.roles_count === 1 ? 'role' : 'roles'}
{' · '}
{org.employees_count} {org.employees_count === 1 ? 'employee' : 'employees'}
{' · '}
{formatRelativeTime(org.saved_at)}
</span>
</div>
{loadingName === org.name ? (
<span className="sos-row-loading">loading</span>
) : isActive ? (
<span className="sos-row-active-chip">active</span>
) : confirmDelete === org.name ? (
<button
type="button"
className="sos-row-delete sos-row-delete--confirm"
onClick={e => { e.stopPropagation(); handleDelete(org.name) }}
>
confirm
</button>
) : (
<button
type="button"
className="sos-row-delete"
onClick={e => { e.stopPropagation(); setConfirmDelete(org.name) }}
title="Delete"
>
delete
</button>
)}
</div>
)
})
)}
</div>
<div className="sos-save-as">
{saveAsMode ? (
<div className="sos-save-as-form">
<input
ref={saveInputRef}
type="text"
className="sos-save-as-input"
placeholder="Organization name"
value={saveAsName}
onChange={e => setSaveAsName(e.target.value)}
onKeyDown={onKeyDown}
/>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={handleSaveAs}
disabled={!saveAsValid}
>
{saveAsExists ? 'Overwrite copy' : 'Save as copy'}
</button>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => { setSaveAsMode(false); setSaveAsName('') }}
>
Cancel
</button>
</div>
) : (
<button
type="button"
className="sos-save-as-trigger"
onClick={() => setSaveAsMode(true)}
>
+ Save as copy...
</button>
)}
{saveAsMode && saveAsName && !saveAsValid && (
<div className="sos-save-as-hint sos-save-as-hint--warn">
Use up to 80 characters. Slashes are not allowed.
</div>
)}
{saveAsMode && saveAsValid && saveAsExists && (
<div className="sos-save-as-hint sos-save-as-hint--warn">
Name exists - this will overwrite that copy.
</div>
)}
</div>
</div>
)}
</div>
)
}