|
@@ -1,9 +1,10 @@
|
|
|
/**
|
|
/**
|
|
|
* Workspace browser tree row components (figma Cell set 14:3080): pure presentational —
|
|
* Workspace browser tree row components (figma Cell set 14:3080): pure presentational —
|
|
|
* all data and callbacks arrive via props. Hover swaps (folder->chevron,
|
|
* all data and callbacks arrive via props. Hover swaps (folder->chevron,
|
|
|
- * time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only
|
|
|
|
|
- * except workspace Rename/Delete and session Rename/Fork/Archive; the session
|
|
|
|
|
- * and workspace hover cards are suppressed while a menu is open.
|
|
|
|
|
|
|
+ * time->ellipsis, action buttons) are CSS-only, and a session row's clipped
|
|
|
|
|
+ * title is scrolled programmatically while the row is hovered. Row ... menus are
|
|
|
|
|
+ * visual-only except workspace Rename/Delete and session Rename/Fork/Archive; the
|
|
|
|
|
+ * session and workspace hover cards are suppressed while a menu is open.
|
|
|
*/
|
|
*/
|
|
|
import { useEffect, useRef, useState } from 'react'
|
|
import { useEffect, useRef, useState } from 'react'
|
|
|
import clsx from 'clsx'
|
|
import clsx from 'clsx'
|
|
@@ -27,6 +28,30 @@ function displayTitle(node: SessionNode, t: RowTranslate): string {
|
|
|
return node.blank ? t('session.new') : node.title
|
|
return node.blank ? t('session.new') : node.title
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * Reveal a title wider than its one-line cell while its row is hovered: the
|
|
|
|
|
+ * title clips its own text, so the far edge (a fork's incremented title, for
|
|
|
|
|
+ * example) is reachable by scrolling the element to its end. Leaving returns it
|
|
|
|
|
+ * to the start in one step, because the resting ellipsis and the narrowed cell
|
|
|
|
|
+ * would otherwise meet the text while it travelled back. A title that fits has
|
|
|
|
|
+ * no scroll range to move, and the stylesheet decides whether either move
|
|
|
|
|
+ * glides or jumps.
|
|
|
|
|
+ * @param title - the row's clipping title element.
|
|
|
|
|
+ * @param revealed - whether the pointer is on the row.
|
|
|
|
|
+ */
|
|
|
|
|
+function revealClippedTitle(title: HTMLSpanElement | null, revealed: boolean): void {
|
|
|
|
|
+ /* v8 ignore next -- defensive: the title span renders unconditionally. */
|
|
|
|
|
+ if (title === null) return
|
|
|
|
|
+ if (revealed) {
|
|
|
|
|
+ title.scrollLeft = title.scrollWidth - title.clientWidth
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ // jsdom implements no scrollTo; the lane's direct assignment is instant there
|
|
|
|
|
+ // anyway, so both paths land on the same resting position.
|
|
|
|
|
+ if (typeof title.scrollTo === 'function') title.scrollTo({ left: 0, behavior: 'instant' })
|
|
|
|
|
+ else title.scrollLeft = 0
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
/** Localized compact relative time ("刚刚"/"5分钟" in zh, "now"/"5min" in en). */
|
|
/** Localized compact relative time ("刚刚"/"5分钟" in zh, "now"/"5min" in en). */
|
|
|
function timeLabel(updatedAt: number, now: number, t: RowTranslate): string {
|
|
function timeLabel(updatedAt: number, now: number, t: RowTranslate): string {
|
|
|
const { unit, n } = relativeTime(updatedAt, now)
|
|
const { unit, n } = relativeTime(updatedAt, now)
|
|
@@ -408,6 +433,7 @@ export function SessionNodeItem({
|
|
|
const draggable = drag !== undefined && !row.blank
|
|
const draggable = drag !== undefined && !row.blank
|
|
|
const [menuOpen, setMenuOpen] = useState(false)
|
|
const [menuOpen, setMenuOpen] = useState(false)
|
|
|
const rowRef = useRef<HTMLDivElement>(null)
|
|
const rowRef = useRef<HTMLDivElement>(null)
|
|
|
|
|
+ const titleRef = useRef<HTMLSpanElement>(null)
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
if (onReveal === undefined) return
|
|
if (onReveal === undefined) return
|
|
|
rowRef.current?.scrollIntoView({ block: 'nearest' })
|
|
rowRef.current?.scrollIntoView({ block: 'nearest' })
|
|
@@ -434,6 +460,8 @@ export function SessionNodeItem({
|
|
|
role="treeitem"
|
|
role="treeitem"
|
|
|
aria-selected={selected}
|
|
aria-selected={selected}
|
|
|
onClick={() => { onOpen(node.id) }}
|
|
onClick={() => { onOpen(node.id) }}
|
|
|
|
|
+ onPointerEnter={() => { revealClippedTitle(titleRef.current, true) }}
|
|
|
|
|
+ onPointerLeave={() => { revealClippedTitle(titleRef.current, false) }}
|
|
|
draggable={draggable}
|
|
draggable={draggable}
|
|
|
onDragStart={drag === undefined || row.blank
|
|
onDragStart={drag === undefined || row.blank
|
|
|
? undefined
|
|
? undefined
|
|
@@ -467,7 +495,7 @@ export function SessionNodeItem({
|
|
|
{showStatus && <SessionStatusDots statuses={statuses} />}
|
|
{showStatus && <SessionStatusDots statuses={statuses} />}
|
|
|
</span>
|
|
</span>
|
|
|
)}
|
|
)}
|
|
|
- <span className={css.title}>{title}</span>
|
|
|
|
|
|
|
+ <span ref={titleRef} className={css.title}>{title}</span>
|
|
|
{row.hasActiveSchedule && <ActiveScheduleIndicator t={t} />}
|
|
{row.hasActiveSchedule && <ActiveScheduleIndicator t={t} />}
|
|
|
{/* A blank New Session row is a provisional placeholder: nothing has
|
|
{/* A blank New Session row is a provisional placeholder: nothing has
|
|
|
happened in it yet, so a "now" timestamp and the row verbs
|
|
happened in it yet, so a "now" timestamp and the row verbs
|