Newer
Older
navi-1 / webclient / src / components / artifacts / composables / useSessionFiles.js
import { computed, ref } from 'vue'

import { useLightbox } from '@/composables/useLightbox.js'
import { t } from '@/i18n/index.js'
import { itemContentType } from '@/utils/artifactContent.js'
import { absoluteContentUrl } from '@/utils/contentLinks.js'

/**
 * The Files tab: the session's directory tree with per-file download / view
 * actions, the refresh button, and the mobile pull-to-refresh gesture.
 */

const FILES_PTR_THRESHOLD = 80
const FILES_PTR_MAX = 120

export function useSessionFiles(chat) {
  const { openContent } = useLightbox()

  const filesRefreshing = ref(false)

  async function refreshFiles() {
    if (filesRefreshing.value || !chat.currentId) return
    filesRefreshing.value = true
    try {
      await chat.fetchFiles(chat.currentId)
    } finally {
      filesRefreshing.value = false
    }
  }

  // ── Pull-to-refresh for files list (mobile) ─────────────────────────────
  const filesPtrPulling = ref(false)
  const filesPtrOffset = ref(0)
  const filesPtrTriggered = ref(false)
  const filesListRef = ref(null)

  const filesPtrStyle = computed(() => ({
    transform: `translateY(${Math.min(filesPtrOffset.value, FILES_PTR_MAX)}px)`,
    opacity: filesPtrPulling.value ? Math.min(filesPtrOffset.value / FILES_PTR_THRESHOLD, 1) : 0,
  }))

  const filesPtrLabel = computed(() => {
    if (filesRefreshing.value) return t('common.refreshing')
    if (filesPtrTriggered.value) return t('common.releaseToRefresh')
    return t('common.pullToRefresh')
  })

  let _filesPtrStartY = 0
  let _filesPtrStartScroll = 0

  function onFilesTouchStart(e) {
    const list = filesListRef.value
    if (!list) return
    _filesPtrStartScroll = list.scrollTop || 0
    if (_filesPtrStartScroll > 2) return
    _filesPtrStartY = e.touches[0]?.clientY ?? 0
    filesPtrPulling.value = false
    filesPtrOffset.value = 0
    filesPtrTriggered.value = false
  }

  function onFilesTouchMove(e) {
    if (_filesPtrStartScroll > 2 || !_filesPtrStartY) return
    const y = e.touches[0]?.clientY ?? 0
    const delta = y - _filesPtrStartY
    if (delta <= 0) {
      filesPtrPulling.value = false
      return
    }
    if (delta > 4) {
      e.preventDefault()
    }
    filesPtrPulling.value = true
    filesPtrOffset.value = delta
    filesPtrTriggered.value = delta >= FILES_PTR_THRESHOLD
  }

  function onFilesTouchEnd() {
    if (filesPtrTriggered.value && !filesRefreshing.value) {
      refreshFiles()
    }
    filesPtrPulling.value = false
    filesPtrOffset.value = 0
    filesPtrTriggered.value = false
    _filesPtrStartY = 0
    _filesPtrStartScroll = 0
  }

  const filesFlat = computed(() => chat.files)
  const fileTree = computed(() => {
    const list = filesFlat.value
    return list.map(item => {
      const parts = item.path.split('/')
      const name = parts[parts.length - 1]
      const depth = parts.length - 1
      return { ...item, name, depth }
    })
  })

  function contentType(item) {
    return itemContentType(item)
  }

  function fileIcon(item) {
    if (item.is_dir) return 'ph ph-folder'
    const ext = item.name.split('.').pop()?.toLowerCase() || ''
    const map = {
      js: 'ph ph-file-js',
      ts: 'ph ph-file-ts',
      py: 'ph ph-file-py',
      html: 'ph ph-file-html',
      css: 'ph ph-file-css',
      // phosphor has no dedicated json glyph — file-code is its stand-in
      json: 'ph ph-file-code',
      md: 'ph ph-file-text',
      txt: 'ph ph-file-text',
      jpg: 'ph ph-file-image',
      jpeg: 'ph ph-file-image',
      png: 'ph ph-file-image',
      gif: 'ph ph-file-image',
      svg: 'ph ph-file-image',
      webp: 'ph ph-file-image',
      mp4: 'ph ph-file-video',
      webm: 'ph ph-file-video',
      mov: 'ph ph-file-video',
      mp3: 'ph ph-file-audio',
      wav: 'ph ph-file-audio',
      ogg: 'ph ph-file-audio',
      pdf: 'ph ph-file-pdf',
      zip: 'ph ph-file-zip',
      tar: 'ph ph-file-zip',
      gz: 'ph ph-file-zip',
      rar: 'ph ph-file-zip',
    }
    return map[ext] || 'ph ph-file'
  }

  function fileMeta(item) {
    if (item.is_dir) return t('artifacts.directory')
    const size = item.size
    if (size < 1024) return `${size} B`
    if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
    return `${(size / (1024 * 1024)).toFixed(1)} MB`
  }

  function fileIndent(item) {
    return { paddingLeft: `${12 + item.depth * 16}px` }
  }

  function fileDownloadUrl(path) {
    const sessionId = chat.currentId
    if (!sessionId) return '#'
    return `/api/sessions/${sessionId}/files/${encodeURIComponent(path)}?download=1`
  }

  // "Open inline" used to append ?download=1 as well, so the eye button saved
  // the file instead of showing it.
  function filePreviewUrl(path) {
    const sessionId = chat.currentId
    if (!sessionId) return '#'
    return `/api/sessions/${sessionId}/files/${encodeURIComponent(path)}`
  }

  function canViewFile(item) {
    if (!item || item.is_dir) return false
    return contentType(item) !== 'unknown'
  }

  // Every type with a viewer opens in the same modal the artifact preview uses:
  // media plays there, text is fetched and highlighted, documents and 3D models
  // get their iframe page.
  function openFileInViewer(item) {
    if (!item || item.is_dir) return
    const type = contentType(item)
    if (type === 'unknown') return
    openContent({
      url: absoluteContentUrl(filePreviewUrl(item.path)),
      type,
      title: item.name,
    })
  }

  return {
    filesRefreshing,
    refreshFiles,
    filesPtrPulling,
    filesPtrOffset,
    filesPtrTriggered,
    filesListRef,
    filesPtrStyle,
    filesPtrLabel,
    onFilesTouchStart,
    onFilesTouchMove,
    onFilesTouchEnd,
    filesFlat,
    fileTree,
    fileIcon,
    fileMeta,
    fileIndent,
    fileDownloadUrl,
    canViewFile,
    openFileInViewer,
  }
}