TryItFileSelection.vue•8.79 kB
<template>
<div class="file-selection-container">
<div class="file-selection-header">
<h3 class="file-selection-title">
<FileText :size="16" class="title-icon" />
File Selection
</h3>
<div class="file-selection-actions">
<button
type="button"
class="action-btn select-all"
@click="selectAll"
:disabled="!hasFiles"
aria-label="Select all files"
>
Select All
</button>
<button
type="button"
class="action-btn deselect-all"
@click="deselectAll"
:disabled="!hasFiles"
aria-label="Deselect all files"
>
Deselect All
</button>
<button
type="button"
class="action-btn repack"
@click="handleRepack"
:disabled="!hasSelectedFiles || loading"
:aria-label="loading ? 'Re-packing selected files' : `Re-pack ${selectedFiles.length} selected files`"
>
{{ loading ? 'Re-packing...' : 'Re-pack Selected' }}
<PackIcon v-if="!loading" :size="14" />
</button>
</div>
</div>
<div class="file-selection-stats">
<span class="stat-item">
{{ selectedFiles.length }} of {{ allFiles.length }} files selected
</span>
<span class="stat-separator">|</span>
<span class="stat-item">
{{ selectedTokens.toLocaleString() }} tokens
({{ totalTokens > 0 ? ((selectedTokens / totalTokens) * 100).toFixed(1) : '0.0' }}%)
</span>
</div>
<div class="file-list-container">
<div class="file-list-scroll">
<table class="file-table" aria-label="File selection table">
<thead>
<tr>
<th class="checkbox-column">
<input
type="checkbox"
:checked="selectedFiles.length === allFiles.length && allFiles.length > 0"
:indeterminate="selectedFiles.length > 0 && selectedFiles.length < allFiles.length"
@change="($event.target as HTMLInputElement).checked ? selectAll() : deselectAll()"
class="header-checkbox"
aria-label="Select or deselect all files"
/>
</th>
<th class="file-path-column">File Path</th>
<th class="tokens-column">Tokens</th>
</tr>
</thead>
<tbody>
<tr
v-for="file in sortedFiles"
:key="file.path"
class="file-row"
:class="{ 'file-row-selected': file.selected }"
@click="toggleFileSelection(file, $event)"
>
<td class="checkbox-cell">
<input
type="checkbox"
v-model="file.selected"
class="file-checkbox"
:aria-label="`Select file ${file.path}`"
/>
</td>
<td class="file-path-cell">
<span class="file-path">{{ file.path }}</span>
</td>
<td class="tokens-cell">
<span class="file-tokens">{{ file.tokenCount.toLocaleString() }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<FileSelectionWarning
v-if="selectedFiles.length > FILE_SELECTION_WARNING_THRESHOLD"
:threshold="FILE_SELECTION_WARNING_THRESHOLD"
/>
</div>
</template>
<script setup lang="ts">
import { FileText } from 'lucide-vue-next';
import { computed, ref, watch } from 'vue';
import { FILE_SELECTION_WARNING_THRESHOLD } from '../../constants/fileSelection';
import type { FileInfo } from '../api/client';
import FileSelectionWarning from './FileSelectionWarning.vue';
import PackIcon from './PackIcon.vue';
interface Props {
allFiles: FileInfo[];
loading?: boolean;
}
type Emits = (e: 'repack', selectedFiles: FileInfo[]) => void;
const props = withDefaults(defineProps<Props>(), {
loading: false,
});
const emit = defineEmits<Emits>();
// Local reactive state to avoid mutating props directly (Vue one-way data flow)
const localFiles = ref<FileInfo[]>([]);
// Sync props.allFiles to localFiles with deep copying to maintain reactivity
watch(
() => props.allFiles,
(newFiles) => {
// Deep clone to avoid mutating props - fallback to JSON method for compatibility
try {
localFiles.value = structuredClone(newFiles || []);
} catch {
localFiles.value = JSON.parse(JSON.stringify(newFiles || []));
}
},
{ immediate: true },
);
const hasFiles = computed(() => localFiles.value.length > 0);
const selectedFiles = computed(() => localFiles.value.filter((file) => file.selected));
const hasSelectedFiles = computed(() => selectedFiles.value.length > 0);
const totalTokens = computed(() => localFiles.value.reduce((sum, file) => sum + file.tokenCount, 0));
const selectedTokens = computed(() => selectedFiles.value.reduce((sum, file) => sum + file.tokenCount, 0));
const sortedFiles = computed(() => [...localFiles.value].sort((a, b) => b.tokenCount - a.tokenCount));
const selectAll = () => {
for (const file of localFiles.value) {
file.selected = true;
}
};
const deselectAll = () => {
for (const file of localFiles.value) {
file.selected = false;
}
};
const handleRepack = () => {
if (hasSelectedFiles.value) {
emit('repack', selectedFiles.value);
}
};
const toggleFileSelection = (file: FileInfo, event?: Event) => {
// Prevent double-toggling when clicking directly on checkbox
if (event?.target && (event.target as HTMLInputElement).type === 'checkbox') {
return;
}
file.selected = !file.selected;
};
</script>
<style scoped>
.file-selection-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid var(--vp-c-border);
background: var(--vp-c-bg-soft);
border-radius: 8px 8px 0 0;
}
.file-selection-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--vp-c-text-1);
display: flex;
align-items: center;
gap: 8px;
}
.title-icon {
color: var(--vp-c-text-2);
}
.file-selection-actions {
display: flex;
gap: 8px;
}
.action-btn {
padding: 6px 12px;
border: 1px solid var(--vp-c-border);
border-radius: 4px;
background: var(--vp-c-bg);
color: var(--vp-c-text-1);
font-size: 13px;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 4px;
}
.action-btn:hover:not(:disabled) {
background: var(--vp-c-bg-soft);
border-color: var(--vp-c-brand-1);
}
.action-btn.repack {
background: var(--vp-c-brand-1);
color: white;
border-color: var(--vp-c-brand-1);
}
.action-btn.repack:hover:not(:disabled) {
background: var(--vp-c-brand-2);
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.file-selection-stats {
padding: 12px 16px;
background: var(--vp-c-bg-alt);
border-bottom: 1px solid var(--vp-c-border);
font-size: 13px;
color: var(--vp-c-text-2);
}
.stat-item {
color: var(--vp-c-text-1);
}
.stat-separator {
margin: 0 8px;
color: var(--vp-c-text-3);
}
.file-list-container {
max-height: 300px;
overflow-y: auto;
background: var(--vp-c-bg);
}
.file-list-scroll {
padding: 0;
}
.file-table {
width: 100%;
border-collapse: collapse;
}
.file-table th {
background: var(--vp-c-bg-soft);
border-bottom: 1px solid var(--vp-c-border);
padding: 8px 12px;
text-align: left;
font-size: 12px;
font-weight: 600;
color: var(--vp-c-text-2);
position: sticky;
top: 0;
}
.checkbox-column {
width: 40px;
text-align: center;
}
.file-path-column {
width: 70%;
}
.file-table .tokens-column {
width: 30%;
text-align: left;
padding-right: 2rem;
}
.file-row {
transition: background-color 0.2s ease;
cursor: pointer;
}
.file-row:hover {
background: var(--vp-c-bg-soft);
}
.file-table td {
padding: 8px 12px;
border-bottom: 1px solid var(--vp-c-border-soft);
}
.checkbox-cell {
text-align: center;
}
.file-path-cell {
max-width: 0;
width: 100%;
}
.file-path {
font-size: 13px;
color: var(--vp-c-text-1);
word-break: break-all;
font-family: var(--vp-font-family-mono);
}
.file-table .tokens-cell {
text-align: left;
padding-right: 2rem;
}
.file-tokens {
font-size: 12px;
color: var(--vp-c-text-2);
white-space: nowrap;
}
.header-checkbox {
cursor: pointer;
}
.file-checkbox {
cursor: pointer;
}
@media (max-width: 768px) {
.file-selection-header {
flex-direction: column;
gap: 12px;
align-items: stretch;
}
.file-selection-actions {
justify-content: space-between;
}
.action-btn {
flex: 1;
justify-content: center;
}
}
</style>