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,
}
}