oxios 1.23.2

Oxios Agent OS — Agent Operating System powered by oxi-sdk
import { useRouterState } from '@tanstack/react-router'
import { ArrowRightLeft } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useKnowledgeFile, useKnowledgeRecursiveTree, useMoveFile } from '@/hooks/use-knowledge'
import { cn } from '@/lib/utils'
import { useKnowledgeStore } from '@/stores/knowledge'

/**
 * MoveModal — Phase 4 (R3 resolution).
 *
 * Replaces the previous implementation that used `writeFile` + `deleteFile`
 * (2-step non-atomic move, git history fragmentation, no backlink reindex).
 * Now delegates to `POST /api/knowledge/move` which calls
 * `KnowledgeBase::note_move` (atomic rename + backlinks + change event).
 *
 * Directory listing now uses the recursive tree endpoint so all directories
 * (including nested ones) are selectable without typing paths manually.
 */
export function MoveModal() {
  const { t } = useTranslation()
  const [open, setOpen] = useState(false)
  const [query, setQuery] = useState('')
  const [focusedIndex, setFocusedIndex] = useState(0)
  const inputRef = useRef<HTMLInputElement>(null)

  const currentFilePath = useKnowledgeStore((s) => s.currentFilePath)
  const openFile = useKnowledgeStore((s) => s.openFile)
  const moveFile = useMoveFile()
  // Re-read current content only to verify the file still exists client-side
  // before the user attempts the move. The actual move uses the path-only API.
  const { data: currentContent } = useKnowledgeFile(currentFilePath)
  const { data: tree } = useKnowledgeRecursiveTree(open)

  // Every directory path in the tree (S7). Empty string represents the root.
  const allDirs = useMemo(() => {
    if (!tree) return ['/']
    return [
      '/',
      ...flattenTree(tree)
        .filter((n) => n.is_dir)
        .map((n) => n.path),
    ]
  }, [tree])

  // Manual path entry — if the user types a `/`-bearing query that
  // doesn't match an existing directory, treat it as a free-form target.
  const manualDir = query.trim().startsWith('/')
    ? query.trim()
    : query.trim().includes('/')
      ? query.trim()
      : null
  const extraDirs = manualDir && !allDirs.includes(manualDir) ? [manualDir] : []
  const candidateDirs = [...allDirs, ...extraDirs]

  const filteredDirs = query.trim()
    ? candidateDirs.filter((d) => d.toLowerCase().includes(query.trim().toLowerCase()))
    : candidateDirs

  // Global ⌘M listener (pathname guarded so the modal doesn't open on chat etc.)
  const router = useRouterState()
  const pathnameRef = useRef(router.location.pathname)
  pathnameRef.current = router.location.pathname

  useEffect(() => {
    const handler = (e: KeyboardEvent) => {
      if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'm') return
      if (!pathnameRef.current.startsWith('/knowledge')) return
      e.preventDefault()
      setOpen(true)
    }
    window.addEventListener('keydown', handler)
    return () => window.removeEventListener('keydown', handler)
  }, [])

  useEffect(() => {
    if (open) inputRef.current?.focus()
  }, [open])

  const close = useCallback(() => setOpen(false), [])

  const handleMove = useCallback(
    async (targetDir: string) => {
      if (!currentFilePath) return
      const filename = currentFilePath.split('/').pop() ?? ''
      const newPath = targetDir === '/' || targetDir === '' ? filename : `${targetDir}/${filename}`
      if (newPath === currentFilePath) {
        close()
        return
      }
      try {
        await moveFile.mutateAsync({ from: currentFilePath, to: newPath })
        openFile(newPath)
      } catch (err) {
        console.error('move failed', err)
      }
      close()
    },
    [currentFilePath, moveFile, openFile, close],
  )

  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent) => {
      if (e.key === 'ArrowDown') {
        e.preventDefault()
        setFocusedIndex((i) => Math.min(i + 1, filteredDirs.length - 1))
      } else if (e.key === 'ArrowUp') {
        e.preventDefault()
        setFocusedIndex((i) => Math.max(i - 1, 0))
      } else if (e.key === 'Enter') {
        e.preventDefault()
        const dir = filteredDirs[focusedIndex]
        if (dir) handleMove(dir)
      } else if (e.key === 'Escape') {
        close()
      }
    },
    [filteredDirs, focusedIndex, close, handleMove],
  )

  if (!open) return null
  if (!currentFilePath) return null
  // Surface a friendly empty state if the active file no longer exists.
  // currentContent is undefined while loading and null if the file is gone.
  if (currentContent === null) return null

  return (
    <div className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]">
      <div className="fixed inset-0 bg-black/30" onClick={close} aria-hidden />
      <div className="relative z-10 w-full max-w-md rounded-lg border bg-popover shadow-lg">
        <div className="flex items-center gap-2 border-b px-3 py-2">
          <ArrowRightLeft className="h-4 w-4 text-muted-foreground" />
          <input
            ref={inputRef}
            value={query}
            onChange={(e) => {
              setQuery(e.target.value)
              setFocusedIndex(0)
            }}
            onKeyDown={handleKeyDown}
            placeholder={t('knowledge.movePlaceholder')}
            className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
          />
          <span className="text-2xs text-muted-foreground/60 font-mono">⌘M</span>
        </div>

        <ul className="max-h-64 overflow-y-auto p-1">
          {filteredDirs.length === 0 && (
            <li className="px-3 py-2 text-xs text-muted-foreground">
              {t('knowledge.moveNoMatch')}
            </li>
          )}
          {filteredDirs.map((dir, idx) => (
            <li key={dir}>
              <button
                type="button"
                onClick={() => handleMove(dir)}
                onMouseEnter={() => setFocusedIndex(idx)}
                className={cn(
                  'flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-left text-sm',
                  idx === focusedIndex ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50',
                )}
              >
                <span className="font-mono text-2xs text-muted-foreground">/</span>
                <span className="truncate">{dir === '/' ? '/' : dir}</span>
              </button>
            </li>
          ))}
        </ul>

        <div className="border-t px-3 py-2 text-2xs text-muted-foreground">
          {t('knowledge.moveFooter')}
        </div>
      </div>
    </div>
  )
}

/** Pre-order traversal that returns every node (file + dir). */
function flattenTree<T extends { is_dir: boolean; path: string; children?: T[] }>(nodes: T[]): T[] {
  const out: T[] = []
  for (const node of nodes) {
    out.push(node)
    if (node.children?.length) out.push(...flattenTree(node.children))
  }
  return out
}