mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
1430056d24
The attempt counter and the backoff were written on every failure and never consulted when planning the next run: rows were found in a live database at 46 attempts against a limit of 6, so a document a provider refuses — one it reads as corrupt, a type it will not take, a full quota — was re-uploaded on every cron tick forever. planReconcile now skips a row that is shelved or still inside its window, and a manual run clears the counters, which is what "waits for a person" has to mean. The webhook had the same shape of bug: WEBHOOK_NUDGE_DEBOUNCE_SECONDS existed, with a comment explaining it, and nothing read it. Providers fire once per document, so twenty files dropped into a watched folder were twenty runs — the in-flight guard answered `busy` to nineteen of them, throwing away nineteen announcements, and the one run that did start had begun before most of the changes landed. A burst now folds into one trailing run, and the binding is re-read when the timer fires so a sync switched off in between does not get a last run out of a stale row. Four defects the new suites turned up, all fixed: the sync button lost its accessible name below sm, where its label is hidden; the stored address was never shown in the connect dialog although the form kept submitting it; the allow-self-signed switch only read the connection at mount, so a stored "on" showed as off and the next save wrote that back; and a refused folder create escaped as an unhandled rejection with nothing said to anybody. Also here: the phone shell gets the same feature (one sheet, four views, the same hook as the desktop), the marks come from the product's own icon files rather than redrawn approximations, and the 22 non-English locales carry the new keys. ADMIN-SVC-069 asserted that `documents` has no MCP surface. It does now, so the test takes its example from MCP_GATED_ADDON_IDS instead of naming one.
214 lines
7.3 KiB
TypeScript
214 lines
7.3 KiB
TypeScript
import { ArrowLeft, ArrowRight } from 'lucide-react'
|
|
import { useTranslation } from '../../../i18n/TranslationContext'
|
|
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
|
|
import TrekMark from '../../shared/TrekMark'
|
|
import { Badge } from './DocSyncBits'
|
|
|
|
export type SyncDirection = 'both' | 'pull' | 'push'
|
|
|
|
export interface FlowHoldings {
|
|
/** Documents this trip has in TREK. */
|
|
inTrek: number
|
|
/** How many of them the store is holding. */
|
|
atProvider: number
|
|
paired: number
|
|
missing: number
|
|
}
|
|
|
|
/**
|
|
* The direction control, drawn as what it actually is.
|
|
*
|
|
* A document sync has one characteristic picture — two places and things moving
|
|
* between them — and a dropdown reading "Both ways" hides it behind a word.
|
|
* Here the two ends carry what each side is holding, the lanes between them
|
|
* carry the direction, and switching direction means switching a lane off. It
|
|
* is the one loud element in the dialog; everything around it stays quiet.
|
|
*
|
|
* The numbers are standing counts, not the last run's transfers: a binding that
|
|
* is already in step transferred nothing, and a panel answering "0" on a
|
|
* healthy connection says nothing at the moment somebody looks at it.
|
|
*/
|
|
export default function DocSyncFlow({
|
|
direction,
|
|
providerId,
|
|
providerName,
|
|
holdings,
|
|
running,
|
|
disabled,
|
|
onChange,
|
|
}: {
|
|
direction: SyncDirection
|
|
providerId: string
|
|
providerName: string
|
|
holdings: FlowHoldings
|
|
/** A run is in flight: the active lanes move while it is. */
|
|
running?: boolean
|
|
disabled?: boolean
|
|
onChange: (next: SyncDirection) => void
|
|
}) {
|
|
const { t } = useTranslation()
|
|
const Icon = DOCUMENT_PROVIDER_ICONS[providerId]
|
|
const pushOn = direction === 'both' || direction === 'push'
|
|
const pullOn = direction === 'both' || direction === 'pull'
|
|
|
|
const hint = disabled
|
|
? t(`docsync.flow.summary.${direction}`, { provider: providerName })
|
|
: t(`docsync.flow.summaryEditable.${direction}`, { provider: providerName })
|
|
|
|
/** Turning a lane off leaves the other one; turning the last one off is refused. */
|
|
const toggle = (lane: 'push' | 'pull') => {
|
|
if (disabled) return
|
|
const nextPush = lane === 'push' ? !pushOn : pushOn
|
|
const nextPull = lane === 'pull' ? !pullOn : pullOn
|
|
if (!nextPush && !nextPull) return
|
|
onChange(nextPush && nextPull ? 'both' : nextPush ? 'push' : 'pull')
|
|
}
|
|
|
|
return (
|
|
<section className="rounded-2xl border border-edge bg-surface-secondary p-4 sm:p-6">
|
|
<div className="flex items-center gap-3 sm:gap-5">
|
|
<End
|
|
name={t('docsync.flow.trek')}
|
|
count={holdings.inTrek}
|
|
glyph={<TrekMark className="h-6 w-6 text-content" />}
|
|
/>
|
|
|
|
<div className="flex min-w-0 flex-1 flex-col gap-2.5">
|
|
<Lane
|
|
active={pushOn}
|
|
running={running}
|
|
disabled={disabled}
|
|
caption={t('docsync.flow.toProvider')}
|
|
onClick={() => toggle('push')}
|
|
>
|
|
<ArrowRight size={13} strokeWidth={2.75} />
|
|
</Lane>
|
|
<Lane
|
|
active={pullOn}
|
|
running={running}
|
|
reverse
|
|
disabled={disabled}
|
|
caption={t('docsync.flow.toTrek')}
|
|
onClick={() => toggle('pull')}
|
|
>
|
|
<ArrowLeft size={13} strokeWidth={2.75} />
|
|
</Lane>
|
|
|
|
{/* Under the lanes rather than under the whole bar: it explains what
|
|
the two buttons above it are doing, and sitting anywhere else
|
|
makes the reader look for what it refers to. */}
|
|
<p className="truncate px-1 text-center text-caption text-content-muted">{hint}</p>
|
|
</div>
|
|
|
|
<End
|
|
name={providerName}
|
|
count={holdings.atProvider}
|
|
glyph={Icon ? <Icon className="h-6 w-6 text-content" /> : null}
|
|
/>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* One end of the flow: the mark, the count, the name.
|
|
*
|
|
* The count is the largest thing in the box on purpose — "is my stuff over
|
|
* there" is the question that makes somebody open this dialog, and it should be
|
|
* answered before they read a word.
|
|
*/
|
|
function End({ name, count, glyph }: { name: string; count: number; glyph: React.ReactNode }) {
|
|
const { t } = useTranslation()
|
|
return (
|
|
<div
|
|
className="flex w-24 shrink-0 flex-col items-center gap-2 rounded-2xl border border-edge bg-surface px-3 py-4 shadow-card sm:w-32"
|
|
>
|
|
<span className="grid h-10 w-10 place-items-center rounded-xl bg-surface-secondary">{glyph}</span>
|
|
|
|
<span className="flex flex-col items-center gap-1">
|
|
<span className="text-title font-semibold tabular-nums leading-none text-content">{count}</span>
|
|
<span className="text-caption uppercase leading-none tracking-wide text-content-faint">
|
|
{t('docsync.flow.documents')}
|
|
</span>
|
|
</span>
|
|
|
|
<Badge tone="neutral" title={name}>
|
|
<span className="max-w-[6.5rem] truncate">{name}</span>
|
|
</Badge>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* One direction, as a track that is either carrying or idle.
|
|
*
|
|
* An idle track stays visible as a dashed line, so switching a direction off
|
|
* reads as a road nobody drives on rather than as something that disappeared.
|
|
*/
|
|
function Lane({
|
|
active,
|
|
running,
|
|
disabled,
|
|
caption,
|
|
reverse,
|
|
onClick,
|
|
children,
|
|
}: {
|
|
active: boolean
|
|
running?: boolean
|
|
disabled?: boolean
|
|
caption: string
|
|
reverse?: boolean
|
|
onClick: () => void
|
|
children: React.ReactNode
|
|
}) {
|
|
const moving = active && running
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
aria-pressed={active}
|
|
className={[
|
|
// No border at all, not a transparent one: a border box across the
|
|
// diagonals still renders a hairline where it meets the corner radius,
|
|
// and that line reads as a broken frame rather than as a hatch.
|
|
'group relative flex h-12 items-center gap-3 overflow-hidden rounded-xl px-3 text-caption transition-all duration-200',
|
|
reverse ? 'flex-row-reverse' : '',
|
|
active
|
|
? 'bg-accent-subtle text-accent-on'
|
|
: 'trek-docsync-lane-off bg-surface text-content-faint',
|
|
disabled ? 'cursor-default' : active ? 'hover:brightness-[0.98]' : 'hover:text-content-muted',
|
|
].join(' ')}
|
|
>
|
|
<span
|
|
className={[
|
|
'relative grid h-7 w-7 shrink-0 place-items-center rounded-lg transition-transform duration-200',
|
|
active ? 'bg-surface shadow-sm' : 'bg-surface-secondary',
|
|
disabled ? '' : reverse ? 'group-hover:-translate-x-0.5' : 'group-hover:translate-x-0.5',
|
|
].join(' ')}
|
|
>
|
|
{children}
|
|
</span>
|
|
|
|
{/* Explicitly aligned to the arrow's side: the dialog inherits a centred
|
|
text-align, which would otherwise float the label away from it. */}
|
|
<span className={`relative min-w-0 flex-1 truncate font-medium ${reverse ? 'text-right' : 'text-left'}`}>
|
|
{caption}
|
|
</span>
|
|
|
|
{/* Only while a run is in flight. An idle lane needs no track — that it is
|
|
on or off is already in its fill — and a permanent line just asks the
|
|
reader what it means. */}
|
|
{moving && (
|
|
<span
|
|
aria-hidden
|
|
className={`trek-docsync-lane-run pointer-events-none absolute inset-x-3 bottom-1.5 h-0.5 rounded-full ${
|
|
reverse ? 'trek-docsync-lane-run-rev' : ''
|
|
}`}
|
|
/>
|
|
)}
|
|
</button>
|
|
)
|
|
}
|