🔄 Refactor GamemasterExplorer.vue to modularize functionality using composables and simplify template structure
This commit is contained in:
@@ -54,529 +54,105 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<li><kbd>Ctrl</kbd>+<kbd>F</kbd> - Focus search</li>
|
<li><kbd>Ctrl</kbd>+<kbd>F</kbd> - Focus search</li>
|
||||||
<li><kbd>Ctrl</kbd>+<kbd>C</kbd> - Copy selected lines</li>
|
<li><kbd>Ctrl</kbd>+<kbd>C</kbd> - Copy selected lines</li>
|
||||||
<li><kbd>Ctrl</kbd>+<kbd>G</kbd> - Go to next search result</li>
|
</template>
|
||||||
<li>
|
|
||||||
<kbd>Shift</kbd>+<kbd>Ctrl</kbd>+<kbd>G</kbd> - Go to previous
|
|
||||||
result
|
|
||||||
</li>
|
|
||||||
<li><kbd>Escape</kbd> - Clear selection / Close dialogs</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Settings Panel -->
|
<script setup>
|
||||||
<div v-if="showSettings" class="settings-panel">
|
import { ref, computed, nextTick, watch } from 'vue';
|
||||||
<h3>Settings</h3>
|
import { GamemasterClient } from '@/utilities/gamemaster-client.js';
|
||||||
<label>
|
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts.js';
|
||||||
<input type="checkbox" v-model="preferences.lineWrap" />
|
import { useUrlState } from '@/composables/useUrlState.js';
|
||||||
Line Wrap
|
import { useClipboard } from '@/composables/useClipboard.js';
|
||||||
</label>
|
import { useGamemasterFiles } from '@/composables/useGamemasterFiles.js';
|
||||||
<label>
|
import { useGamemasterSearch } from '@/composables/useGamemasterSearch.js';
|
||||||
<input type="checkbox" v-model="preferences.darkMode" />
|
import { useLineSelection } from '@/composables/useLineSelection.js';
|
||||||
Dark Mode
|
import useJsonFilter from '@/composables/useJsonFilter.js';
|
||||||
</label>
|
import SearchBar from '@/components/gamemaster/SearchBar.vue';
|
||||||
<label>
|
import FileSelector from '@/components/gamemaster/FileSelector.vue';
|
||||||
<input type="checkbox" v-model="preferences.showLineNumbers" />
|
import JsonViewer from '@/components/gamemaster/JsonViewer.vue';
|
||||||
Show Line Numbers
|
import FilterPanel from '@/components/gamemaster/FilterPanel.vue';
|
||||||
</label>
|
import ActionToolbar from '@/components/gamemaster/ActionToolbar.vue';
|
||||||
<label>
|
|
||||||
Performance:
|
|
||||||
<select v-model="preferences.performanceMode">
|
|
||||||
<option value="auto">Auto</option>
|
|
||||||
<option value="high">High</option>
|
|
||||||
<option value="medium">Medium</option>
|
|
||||||
<option value="low">Low</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- File Selector -->
|
const client = new GamemasterClient();
|
||||||
<FileSelector :files-state="filesState" />
|
const filesState = useGamemasterFiles(client);
|
||||||
|
const {
|
||||||
|
selectedFile,
|
||||||
|
fileContent,
|
||||||
|
fileLines,
|
||||||
|
displayLines,
|
||||||
|
isLoading,
|
||||||
|
fileError,
|
||||||
|
preferences,
|
||||||
|
hasFiles,
|
||||||
|
fileTooLarge
|
||||||
|
} = filesState;
|
||||||
|
|
||||||
<SearchBar
|
const searchState = useGamemasterSearch(fileLines, displayLines);
|
||||||
v-if="fileContent"
|
const selectionState = useLineSelection(displayLines, fileContent, selectedFile);
|
||||||
:file-lines="fileLines"
|
const filterState = useJsonFilter();
|
||||||
:display-lines="displayLines"
|
|
||||||
:search-state="searchState"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Property Filter -->
|
const showHelp = ref(false);
|
||||||
<FilterPanel
|
const showSettings = ref(false);
|
||||||
v-if="fileContent"
|
const operationProgress = ref({
|
||||||
:data="filterData"
|
|
||||||
:filter-state="filterState"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Progress Bar (for long operations) -->
|
|
||||||
<div v-if="operationProgress.active" class="progress-bar-container">
|
|
||||||
<div
|
|
||||||
class="progress-bar"
|
|
||||||
:class="{ complete: operationProgress.complete }"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="progress-fill"
|
|
||||||
:style="{ width: operationProgress.percent + '%' }"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<span class="progress-text">{{ operationProgress.message }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- JSON Content Viewer -->
|
|
||||||
<JsonViewer
|
|
||||||
v-if="fileContent"
|
|
||||||
:display-lines="displayLines"
|
|
||||||
:file-content="fileContent"
|
|
||||||
:selected-file="selectedFile"
|
|
||||||
:file-too-large="fileTooLarge"
|
|
||||||
:preferences="preferences"
|
|
||||||
:search-results="searchResults"
|
|
||||||
:current-result-index="currentResultIndex"
|
|
||||||
:highlight-config="highlightConfig"
|
|
||||||
:line-height="lineHeight"
|
|
||||||
:selection-state="selectionState"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Action Bar -->
|
|
||||||
<ActionToolbar
|
|
||||||
v-if="fileContent"
|
|
||||||
:display-lines="displayLines"
|
|
||||||
:file-content="fileContent"
|
|
||||||
:selected-file="selectedFile"
|
|
||||||
:selection-state="selectionState"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Toast Notifications -->
|
|
||||||
<div v-if="clipboard.copied.value" class="toast success">
|
|
||||||
✓ Copied to clipboard!
|
|
||||||
</div>
|
|
||||||
<div v-if="clipboard.error.value" class="toast error">
|
|
||||||
✗ {{ clipboard.error.value }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, computed, onMounted, nextTick, watch } from 'vue';
|
|
||||||
import { RecycleScroller } from 'vue-virtual-scroller';
|
|
||||||
import { GamemasterClient } from '@/utilities/gamemaster-client.js';
|
|
||||||
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts.js';
|
|
||||||
import { useUrlState } from '@/composables/useUrlState.js';
|
|
||||||
import { useClipboard } from '@/composables/useClipboard.js';
|
|
||||||
import {
|
|
||||||
useLocalStorage,
|
|
||||||
useSearchHistory
|
|
||||||
} from '@/composables/useLocalStorage.js';
|
|
||||||
import {
|
|
||||||
perfMonitor,
|
|
||||||
debounce,
|
|
||||||
getDevicePerformance
|
|
||||||
} from '@/utilities/performance-utils.js';
|
|
||||||
import {
|
|
||||||
extractJsonPaths,
|
|
||||||
extractJsonPathsLazy
|
|
||||||
} from '@/utilities/json-utils.js';
|
|
||||||
|
|
||||||
// Core state
|
|
||||||
const loading = ref(true);
|
|
||||||
const error = ref(null);
|
|
||||||
const status = ref({});
|
|
||||||
const selectedFile = ref('');
|
|
||||||
const fileContent = ref('');
|
|
||||||
const fileLines = ref([]);
|
|
||||||
const displayLines = ref([]);
|
|
||||||
const virtualScroller = ref(null); // Ref for the virtual scroller
|
|
||||||
|
|
||||||
// UI state
|
|
||||||
const showHelp = ref(false);
|
|
||||||
const showSettings = ref(false);
|
|
||||||
const searchInput = ref(null);
|
|
||||||
|
|
||||||
// Search state
|
|
||||||
const searchQuery = ref('');
|
|
||||||
const searchResults = ref([]);
|
|
||||||
const currentResultIndex = ref(0);
|
|
||||||
const searchHistory = useSearchHistory();
|
|
||||||
|
|
||||||
// Filter state
|
|
||||||
const filterProperty = ref('');
|
|
||||||
const filterValue = ref('');
|
|
||||||
const filterMode = ref('OR');
|
|
||||||
const jsonPaths = ref([]);
|
|
||||||
|
|
||||||
// Selection state
|
|
||||||
const selectedLines = ref(new Set());
|
|
||||||
|
|
||||||
// Progress state
|
|
||||||
const operationProgress = ref({
|
|
||||||
active: false,
|
active: false,
|
||||||
percent: 0,
|
percent: 0,
|
||||||
message: '',
|
message: '',
|
||||||
complete: false
|
complete: false
|
||||||
});
|
|
||||||
|
|
||||||
// Preferences (persisted)
|
|
||||||
const preferences = useLocalStorage('gamemaster-explorer-prefs', {
|
|
||||||
darkMode: false,
|
|
||||||
lineWrap: false,
|
|
||||||
showLineNumbers: true,
|
|
||||||
performanceMode: 'auto',
|
|
||||||
lastFile: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
// Composables
|
|
||||||
const clipboard = useClipboard();
|
|
||||||
const client = new GamemasterClient();
|
|
||||||
|
|
||||||
// Web Worker for search operations
|
|
||||||
let searchWorker = null;
|
|
||||||
let searchWorkerRequestId = 0;
|
|
||||||
let searchWorkerInitPromise = null;
|
|
||||||
|
|
||||||
const initSearchWorker = () => {
|
|
||||||
if (!searchWorkerInitPromise) {
|
|
||||||
searchWorkerInitPromise = import('../workers/search.worker.js?worker')
|
|
||||||
.then(module => {
|
|
||||||
searchWorker = new module.default();
|
|
||||||
console.log('✅ Worker created successfully');
|
|
||||||
|
|
||||||
searchWorker.onmessage = handleSearchWorkerMessage;
|
|
||||||
searchWorker.onerror = error => {
|
|
||||||
console.error('❌ Worker error:', error.message);
|
|
||||||
operationProgress.value.active = false;
|
|
||||||
operationProgress.value.message = 'Search error: ' + error.message;
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('✅ Worker event handlers attached');
|
|
||||||
return searchWorker;
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('❌ Failed to import worker:', error);
|
|
||||||
searchWorker = null;
|
|
||||||
searchWorkerInitPromise = null; // Reset so we can retry
|
|
||||||
throw error;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return searchWorkerInitPromise;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearchWorkerMessage = event => {
|
|
||||||
const { type, id, results, percent, error: workerError } = event.data;
|
|
||||||
console.log('📨 Worker message received:', type, {
|
|
||||||
percent,
|
|
||||||
resultCount: results?.length
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (type === 'progress') {
|
const loading = computed(() => isLoading.value);
|
||||||
operationProgress.value.percent = percent;
|
const error = computed(() => fileError.value);
|
||||||
operationProgress.value.message = `Searching... ${Math.round(percent)}%`;
|
|
||||||
console.log('📊 Progress update:', percent + '%');
|
|
||||||
} else if (type === 'complete') {
|
|
||||||
console.log('✅ Search completed:', results.length, 'results found');
|
|
||||||
searchResults.value = results;
|
|
||||||
currentResultIndex.value = 0;
|
|
||||||
|
|
||||||
// Update displayLines with match highlighting
|
const lineHeight = computed(() => (globalThis.innerWidth < 768 ? 24 : 20));
|
||||||
displayLines.value.forEach(line => (line.hasMatch = false));
|
const highlightConfig = computed(() => ({
|
||||||
results.forEach(lineIndex => {
|
|
||||||
if (displayLines.value[lineIndex]) {
|
|
||||||
displayLines.value[lineIndex].hasMatch = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
operationProgress.value.complete = true;
|
|
||||||
setTimeout(() => {
|
|
||||||
operationProgress.value.active = false;
|
|
||||||
}, 500);
|
|
||||||
// Scroll to first result if found
|
|
||||||
if (results.length > 0) {
|
|
||||||
nextTick(() => scrollToResult());
|
|
||||||
}
|
|
||||||
} else if (type === 'error') {
|
|
||||||
console.error('❌ Search worker error:', workerError);
|
|
||||||
operationProgress.value.active = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Composables
|
|
||||||
const hasFiles = computed(() => status.value.totalFiles > 0);
|
|
||||||
const fileTooLarge = computed(() => fileLines.value.length > 10000);
|
|
||||||
const lineHeight = computed(() => {
|
|
||||||
// Mobile-first: 24px on mobile, 20px on desktop
|
|
||||||
return globalThis.innerWidth < 768 ? 24 : 20;
|
|
||||||
});
|
|
||||||
const highlightConfig = computed(() => ({
|
|
||||||
theme: preferences.value.darkMode ? 'github-dark' : 'github',
|
theme: preferences.value.darkMode ? 'github-dark' : 'github',
|
||||||
language: 'json'
|
language: 'json'
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Get unique files by type (only show one of each file type, prefer largest)
|
const filterData = computed(() => {
|
||||||
const uniqueFiles = computed(() => {
|
if (!fileContent.value) return [];
|
||||||
const fileMap = new Map();
|
try {
|
||||||
|
const parsed = JSON.parse(fileContent.value);
|
||||||
status.value.available?.forEach(file => {
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
const fileType = getFileType(file.filename);
|
} catch {
|
||||||
const existing = fileMap.get(fileType);
|
return [];
|
||||||
|
|
||||||
// Keep the largest file of each type
|
|
||||||
if (!existing || file.size > existing.size) {
|
|
||||||
fileMap.set(fileType, file);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Return files in consistent order
|
useUrlState({
|
||||||
return Array.from(fileMap.values()).sort((a, b) => {
|
|
||||||
const order = { pokemon: 0, allForms: 1, moves: 2, raw: 3 };
|
|
||||||
const typeA = getFileType(a.filename);
|
|
||||||
const typeB = getFileType(b.filename);
|
|
||||||
return (order[typeA] ?? 999) - (order[typeB] ?? 999);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Helper to get highlighted content for search results
|
|
||||||
function getHighlightedContent(lineContent) {
|
|
||||||
if (!searchQuery.value.trim()) return lineContent;
|
|
||||||
|
|
||||||
const searchTerm = searchQuery.value;
|
|
||||||
const escapedTerm = searchTerm.replace(
|
|
||||||
/[.*+?^${}()|[\]\\]/g,
|
|
||||||
String.raw`\$&`
|
|
||||||
);
|
|
||||||
const regex = new RegExp(`(${escapedTerm})`, 'gi');
|
|
||||||
return lineContent.replaceAll(regex, '<mark>$1</mark>');
|
|
||||||
}
|
|
||||||
|
|
||||||
// URL state sync
|
|
||||||
const urlStateRefs = {
|
|
||||||
file: selectedFile,
|
file: selectedFile,
|
||||||
search: searchQuery,
|
search: searchState.searchQuery,
|
||||||
filter: filterProperty,
|
filter: filterState.filterProperty,
|
||||||
value: filterValue
|
value: filterState.filterValue
|
||||||
};
|
});
|
||||||
useUrlState(urlStateRefs);
|
|
||||||
|
|
||||||
// Keyboard shortcuts
|
const clipboard = useClipboard();
|
||||||
useKeyboardShortcuts({
|
|
||||||
|
useKeyboardShortcuts({
|
||||||
'ctrl+f': () => {
|
'ctrl+f': () => {
|
||||||
showHelp.value = false;
|
showHelp.value = false;
|
||||||
showSettings.value = false;
|
showSettings.value = false;
|
||||||
nextTick(() => searchInput.value?.focus());
|
|
||||||
},
|
},
|
||||||
'ctrl+c': copySelected,
|
'ctrl+c': selectionState.copySelected,
|
||||||
'ctrl+g': goToNextResult,
|
'ctrl+g': searchState.goToNextResult,
|
||||||
'shift+ctrl+g': goToPrevResult,
|
'shift+ctrl+g': searchState.goToPrevResult,
|
||||||
escape: () => {
|
escape: () => {
|
||||||
if (showHelp.value || showSettings.value) {
|
if (showHelp.value || showSettings.value) {
|
||||||
showHelp.value = false;
|
showHelp.value = false;
|
||||||
showSettings.value = false;
|
showSettings.value = false;
|
||||||
} else if (selectedLines.value.size > 0) {
|
} else if (selectionState.selectedLines.value.size > 0) {
|
||||||
selectedLines.value.clear();
|
selectionState.selectedLines.value.clear();
|
||||||
} else if (searchQuery.value) {
|
} else if (searchState.searchQuery.value) {
|
||||||
clearSearch();
|
searchState.clearSearch();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Watch selectedFile changes to load the file
|
|
||||||
watch(selectedFile, newFile => {
|
|
||||||
if (newFile) {
|
|
||||||
loadFile();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Methods
|
|
||||||
async function loadStatus() {
|
|
||||||
try {
|
|
||||||
loading.value = true;
|
|
||||||
error.value = null;
|
|
||||||
status.value = await perfMonitor('Load Status', async () => {
|
|
||||||
return await client.getStatus();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-load last file if set
|
watch(selectedFile, () => {
|
||||||
if (preferences.value.lastFile && !selectedFile.value) {
|
if (selectionState.selectedLines.value.size > 0) {
|
||||||
selectedFile.value = preferences.value.lastFile;
|
selectionState.clearSelection();
|
||||||
await loadFile();
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
error.value = err.message;
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadFile() {
|
|
||||||
if (!selectedFile.value) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
loading.value = true;
|
|
||||||
error.value = null;
|
|
||||||
|
|
||||||
// Check if raw file is too large (> 50MB)
|
|
||||||
if (selectedFile.value === 'raw') {
|
|
||||||
const rawFile = status.value.available?.find(f =>
|
|
||||||
f.filename.includes('raw')
|
|
||||||
);
|
|
||||||
if (rawFile && rawFile.size > 50 * 1024 * 1024) {
|
|
||||||
error.value =
|
|
||||||
'⚠️ Raw gamemaster file is very large (' +
|
|
||||||
formatSize(rawFile.size) +
|
|
||||||
'). It may be slow to load. Try a specific file type instead (Pokemon, All Forms, or Moves).';
|
|
||||||
loading.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let data;
|
|
||||||
switch (selectedFile.value) {
|
|
||||||
case 'pokemon':
|
|
||||||
data = await perfMonitor('Load Pokemon', () => client.getPokemon());
|
|
||||||
break;
|
|
||||||
case 'allForms':
|
|
||||||
data = await perfMonitor('Load All Forms', () => client.getAllForms());
|
|
||||||
break;
|
|
||||||
case 'moves':
|
|
||||||
data = await perfMonitor('Load Moves', () => client.getMoves());
|
|
||||||
break;
|
|
||||||
case 'raw':
|
|
||||||
data = await perfMonitor('Load Raw', () => client.getRaw());
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
throw new Error('Unknown file type');
|
|
||||||
}
|
|
||||||
|
|
||||||
fileContent.value = JSON.stringify(data, null, 2);
|
|
||||||
fileLines.value = fileContent.value.split('\n');
|
|
||||||
|
|
||||||
// Display limited lines initially (10K) but keep full content for searching
|
|
||||||
const linesToDisplay = fileLines.value.slice(0, 10000);
|
|
||||||
displayLines.value = linesToDisplay.map((content, index) => ({
|
|
||||||
lineNumber: index + 1,
|
|
||||||
content,
|
|
||||||
hasMatch: false
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Extract JSON paths for filtering (in background)
|
|
||||||
jsonPaths.value = extractJsonPaths(data);
|
|
||||||
extractJsonPathsLazy(data, paths => {
|
|
||||||
jsonPaths.value = paths;
|
|
||||||
});
|
});
|
||||||
|
</script>
|
||||||
// Save last file preference
|
|
||||||
preferences.value.lastFile = selectedFile.value;
|
|
||||||
|
|
||||||
loading.value = false;
|
|
||||||
} catch (err) {
|
|
||||||
error.value = err.message;
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onFileChange() {
|
|
||||||
// Clear state when file selection changes
|
|
||||||
searchQuery.value = '';
|
|
||||||
searchResults.value = [];
|
|
||||||
currentResultIndex.value = 0;
|
|
||||||
filterProperty.value = '';
|
|
||||||
filterValue.value = '';
|
|
||||||
selectedLines.value.clear();
|
|
||||||
jsonPaths.value = [];
|
|
||||||
// File loading is handled by the watch() on selectedFile
|
|
||||||
}
|
|
||||||
|
|
||||||
const onSearchInput = debounce(async () => {
|
|
||||||
if (!searchQuery.value.trim()) {
|
|
||||||
searchResults.value = [];
|
|
||||||
displayLines.value.forEach(line => (line.hasMatch = false));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize worker if needed (wait for it to load)
|
|
||||||
try {
|
|
||||||
await initSearchWorker();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Failed to initialize worker:', error);
|
|
||||||
operationProgress.value.active = false;
|
|
||||||
operationProgress.value.message = 'Worker initialization failed';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!searchWorker) {
|
|
||||||
console.error('❌ Worker not available');
|
|
||||||
operationProgress.value.active = false;
|
|
||||||
operationProgress.value.message = 'Search worker not available';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show progress for long searches
|
|
||||||
const searchTerm = searchQuery.value.toLowerCase();
|
|
||||||
console.log('🔍 Starting worker search for:', searchTerm);
|
|
||||||
operationProgress.value = {
|
|
||||||
active: true,
|
|
||||||
percent: 0,
|
|
||||||
message: 'Searching...',
|
|
||||||
complete: false
|
|
||||||
};
|
|
||||||
|
|
||||||
// Offload search to worker to avoid blocking UI
|
|
||||||
searchWorkerRequestId++;
|
|
||||||
const requestId = searchWorkerRequestId;
|
|
||||||
|
|
||||||
console.log('📤 Posting message to worker:', {
|
|
||||||
linesCount: fileLines.value.length,
|
|
||||||
searchTerm,
|
|
||||||
requestId
|
|
||||||
});
|
|
||||||
|
|
||||||
// Convert reactive array to plain array (Web Workers can't clone Vue proxies)
|
|
||||||
const plainLines = Array.from(fileLines.value);
|
|
||||||
|
|
||||||
searchWorker.postMessage({
|
|
||||||
lines: plainLines,
|
|
||||||
searchTerm: searchTerm,
|
|
||||||
id: requestId
|
|
||||||
});
|
|
||||||
|
|
||||||
// Store current request ID to ignore stale results
|
|
||||||
searchWorkerRequestId = requestId;
|
|
||||||
|
|
||||||
// Add to search history
|
|
||||||
searchHistory.addToHistory(searchQuery.value);
|
|
||||||
}, 300);
|
|
||||||
|
|
||||||
function clearSearch() {
|
|
||||||
searchQuery.value = '';
|
|
||||||
searchResults.value = [];
|
|
||||||
displayLines.value.forEach(line => (line.hasMatch = false));
|
|
||||||
}
|
|
||||||
|
|
||||||
function goToNextResult() {
|
|
||||||
if (searchResults.value.length === 0) return;
|
|
||||||
currentResultIndex.value =
|
|
||||||
(currentResultIndex.value + 1) % searchResults.value.length;
|
|
||||||
scrollToResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
function goToPrevResult() {
|
|
||||||
if (searchResults.value.length === 0) return;
|
|
||||||
currentResultIndex.value =
|
|
||||||
(currentResultIndex.value - 1 + searchResults.value.length) %
|
|
||||||
searchResults.value.length;
|
|
||||||
scrollToResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollToResult() {
|
|
||||||
const lineIndex = searchResults.value[currentResultIndex.value];
|
|
||||||
if (lineIndex === undefined) return;
|
|
||||||
|
|
||||||
const lineNumber = lineIndex + 1; // Convert to 1-based line number
|
|
||||||
|
|
||||||
// If result is beyond currently displayed lines, load more lines
|
|
||||||
if (
|
|
||||||
lineIndex >= displayLines.value.length &&
|
|
||||||
lineIndex < fileLines.value.length
|
|
||||||
) {
|
|
||||||
// Expand displayLines to include the result
|
|
||||||
const newLinesToDisplay = fileLines.value.slice(
|
|
||||||
0,
|
0,
|
||||||
Math.min(lineIndex + 1000, fileLines.value.length)
|
Math.min(lineIndex + 1000, fileLines.value.length)
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user