Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion components/files/DownloadDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
type="text"
placeholder="myfile.dat"
class="w-full rounded-md border border-autonomi-border bg-autonomi-surface px-3 py-2 text-sm text-autonomi-text focus:border-autonomi-blue focus:outline-none"
@input="onFilenameInput"
@keyup.enter="confirm"
/>
</div>
Expand All @@ -53,33 +54,84 @@
</template>

<script setup lang="ts">
import { useFilesStore } from '~/stores/files'

const props = defineProps<{ open: boolean }>()
const emit = defineEmits<{
close: []
download: [address: string, filename: string]
}>()

const filesStore = useFilesStore()

const inputEl = ref<HTMLInputElement | null>(null)
const address = ref('')
const filename = ref('')
/** Tracks whether the user has edited the filename since the last prefill,
* so a subsequent address change doesn't clobber their typing. */
const filenameDirty = ref(false)

const valid = computed(() => {
const addr = address.value.trim()
const isHex = /^(0x)?[0-9a-fA-F]{8,}$/.test(addr)
return isHex && filename.value.trim().length > 0
})

/** Prior upload that matches the currently-typed address, if any. Drives
* both the filename prefill and the extension-append fallback on submit. */
const matchedEntry = computed(() => {
const addr = address.value.trim()
if (!/^(0x)?[0-9a-fA-F]{8,}$/.test(addr)) return undefined
return filesStore.findUploadByAddress(addr)
})

watch(() => props.open, (val) => {
if (val) {
address.value = ''
filename.value = ''
filenameDirty.value = false
nextTick(() => inputEl.value?.focus())
}
})

watch(matchedEntry, (match) => {
if (!match) return
if (filenameDirty.value) return
filename.value = match.name
})

function onFilenameInput() {
filenameDirty.value = true
}

function confirm() {
if (!valid.value) return
emit('download', address.value.trim(), filename.value.trim())
const typed = filename.value.trim()
const final = appendExtensionIfNeeded(typed, matchedEntry.value?.name)
emit('download', address.value.trim(), final)
emit('close')
}

/** If the user typed a filename without an extension and we know the
* original extension from a history match, append it silently. Keeps
* "screenshot" → "screenshot.png" without nagging, while leaving
* deliberate unextensioned names alone when there's no match to copy from. */
function appendExtensionIfNeeded(typed: string, originalName?: string): string {
if (!originalName) return typed
if (hasExtension(typed)) return typed
const origExt = extensionOf(originalName)
if (!origExt) return typed
return `${typed}.${origExt}`
}

function hasExtension(name: string): boolean {
const dot = name.lastIndexOf('.')
return dot > 0 && dot < name.length - 1
}

function extensionOf(name: string): string | null {
const dot = name.lastIndexOf('.')
if (dot <= 0 || dot === name.length - 1) return null
return name.slice(dot + 1)
}
</script>
43 changes: 37 additions & 6 deletions pages/files.vue
Original file line number Diff line number Diff line change
Expand Up @@ -120,17 +120,43 @@
<span v-if="file.gas_cost" class="block text-[10px] text-autonomi-muted/60">+ {{ file.gas_cost }} gas</span>
</td>
<td class="px-4 py-2.5">
<span
<div
v-if="file.data_map_file"
class="cursor-pointer font-mono text-xs text-autonomi-muted hover:text-autonomi-blue"
:title="`Reveal ${datamapBasename(file.data_map_file)} in its folder`"
@click.stop="openFolder(file.data_map_file)"
class="group flex items-center gap-1.5"
>
{{ datamapBasename(file.data_map_file) }}
</span>
<span
class="cursor-pointer font-mono text-xs text-autonomi-muted hover:text-autonomi-blue"
title="Reveal datamap file in Finder/Explorer"
@click.stop="openFolder(file.data_map_file)"
>
{{ datamapBasename(file.data_map_file) }}
</span>
<button
type="button"
class="rounded p-0.5 text-autonomi-muted/60 hover:text-autonomi-blue hover:bg-autonomi-surface"
title="Copy datamap file path"
@click.stop="copyDatamapPath(file.data_map_file!)"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M7 3.5A1.5 1.5 0 018.5 2h3.879a1.5 1.5 0 011.06.44l3.122 3.12A1.5 1.5 0 0117 6.622V12.5a1.5 1.5 0 01-1.5 1.5h-1v-3.379a3 3 0 00-.879-2.121L10.5 5.379A3 3 0 008.379 4.5H7v-1z" />
<path d="M4.5 6A1.5 1.5 0 003 7.5v9A1.5 1.5 0 004.5 18h7a1.5 1.5 0 001.5-1.5v-5.879a1.5 1.5 0 00-.44-1.06L9.44 6.439A1.5 1.5 0 008.378 6H4.5z" />
</svg>
</button>
<button
type="button"
class="rounded p-0.5 text-autonomi-muted/60 hover:text-autonomi-blue hover:bg-autonomi-surface"
title="Reveal datamap file in Finder/Explorer"
@click.stop="openFolder(file.data_map_file)"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M3.75 3A1.75 1.75 0 002 4.75v10.5c0 .966.784 1.75 1.75 1.75h12.5A1.75 1.75 0 0018 15.25v-8.5A1.75 1.75 0 0016.25 5h-5.982L8.97 3.703A2.5 2.5 0 007.2 3H3.75z" />
</svg>
</button>
</div>
<span
v-else-if="file.address"
class="cursor-pointer font-mono text-xs text-autonomi-muted hover:text-autonomi-blue"
title="Copy network address"
@click.stop="copyAddress(file.address)"
>
{{ truncateAddress(file.address, 8, 6) }}
Expand Down Expand Up @@ -810,6 +836,11 @@ function copyAddress(addr: string) {
toastStore.add('Address copied to clipboard', 'info')
}

function copyDatamapPath(path: string) {
navigator.clipboard.writeText(path)
toastStore.add('Datamap path copied to clipboard', 'info')
}

function datamapBasename(path: string): string {
return path.split(/[\\/]/).pop() ?? path
}
Expand Down
184 changes: 184 additions & 0 deletions pages/settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,60 @@
</div>
</div>

<!-- Rescue Datamaps -->
<div class="rounded-lg border border-autonomi-border p-4">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<h3 class="text-sm font-medium">Rescue Datamaps</h3>
<p class="text-xs text-autonomi-muted">
Re-import private-upload datamaps that exist on disk but are no longer in your upload history
(e.g. after clearing history or reinstalling the app).
</p>
</div>
<button
class="shrink-0 rounded-md border border-autonomi-border px-2.5 py-1.5 text-xs text-autonomi-muted hover:text-autonomi-text disabled:opacity-50"
:disabled="rescueScanning"
@click="scanOrphans"
>
{{ rescueScanning ? 'Scanning...' : 'Scan' }}
</button>
</div>

<div v-if="rescueScanned" class="mt-3">
<div v-if="orphanDatamaps.length === 0" class="rounded-md border border-dashed border-autonomi-border px-3 py-4 text-center text-xs text-autonomi-muted">
No orphaned datamaps found.
</div>
<div v-else class="space-y-2">
<div class="max-h-48 overflow-y-auto rounded-md border border-autonomi-border">
<ul class="divide-y divide-autonomi-border">
<li
v-for="orphan in orphanDatamaps"
:key="orphan.path"
class="flex items-center justify-between gap-2 px-3 py-2 text-xs"
>
<div class="min-w-0">
<div class="truncate text-autonomi-text">{{ orphan.suggested_name }}</div>
<div class="truncate font-mono text-[11px] text-autonomi-muted">
{{ orphan.path }}
</div>
</div>
<div class="shrink-0 text-right text-[11px] text-autonomi-muted">
{{ formatShortDate(orphan.modified_at) }}
</div>
</li>
</ul>
</div>
<button
class="rounded-md bg-autonomi-blue px-2.5 py-1.5 text-xs font-medium text-white hover:opacity-90 disabled:opacity-50"
:disabled="rescueImporting"
@click="importOrphans"
>
{{ rescueImporting ? 'Importing...' : `Import ${orphanDatamaps.length} datamap${orphanDatamaps.length === 1 ? '' : 's'}` }}
</button>
</div>
</div>
</div>

<!-- Diagnostics -->
<div class="rounded-lg border border-autonomi-border p-4">
<div class="flex items-center justify-between">
Expand Down Expand Up @@ -383,13 +437,15 @@ import { isValidEthAddress } from '~/utils/validators'
import { useToastStore } from '~/stores/toasts'
import { useErrorLogStore } from '~/stores/errorlog'
import { useUpdaterStore } from '~/stores/updater'
import { useFilesStore, type UploadHistoryEntry } from '~/stores/files'

const settingsStore = useSettingsStore()
const walletStore = useWalletStore()
const nodesStore = useNodesStore()
const toasts = useToastStore()
const errorLogStore = useErrorLogStore()
const updaterStore = useUpdaterStore()
const filesStore = useFilesStore()
const showAdvanced = ref(false)
const showLog = ref(false)
const appVersion = ref('0.1.0')
Expand Down Expand Up @@ -619,5 +675,133 @@ function clearLog() {
toasts.add('Log cleared', 'info')
}

// ── Rescue Datamaps (V2-195) ──

interface OrphanDatamap {
path: string
suggested_name: string
modified_at: string
}

const rescueScanning = ref(false)
const rescueScanned = ref(false)
const rescueImporting = ref(false)
const orphanDatamaps = ref<OrphanDatamap[]>([])

async function scanOrphans() {
rescueScanning.value = true
try {
if (!filesStore.historyLoaded) {
await filesStore.loadHistory()
}
const knownPaths = filesStore.files
.filter(f => f.kind === 'upload' && f.data_map_file)
.map(f => f.data_map_file!)
orphanDatamaps.value = await invoke<OrphanDatamap[]>('scan_orphan_datamaps', {
knownPaths,
})
rescueScanned.value = true
} catch (e: any) {
toasts.add(`Scan failed: ${e.message ?? e}`, 'error')
} finally {
rescueScanning.value = false
}
}

async function importOrphans() {
rescueImporting.value = true
try {
const newEntries: UploadHistoryEntry[] = []
for (const orphan of orphanDatamaps.value) {
// Read the datamap JSON so we can compute its network address. Without
// the address the history row can't participate in re-download flows.
let json: string
try {
json = await invoke<string>('read_datamap_file', { path: orphan.path })
} catch {
// Skip datamaps we can't read — they stay as orphans for the user
// to re-scan later once they've fixed permissions / disk issues.
continue
}
const address = await sha256Hex(json)
newEntries.push({
name: orphan.suggested_name,
size_bytes: 0,
address,
cost: null,
uploaded_at: orphan.modified_at,
data_map_file: orphan.path,
})
}

// Append, skipping any address already in history (shouldn't happen since
// we filtered by known path, but a computed address could coincidentally
// collide with an address we already have from some other path).
const existingAddrs = new Set(
filesStore.files
.filter(f => f.kind === 'upload' && f.address)
.map(f => f.address!.toLowerCase()),
)
const toImport = newEntries.filter(e => !existingAddrs.has(e.address.toLowerCase()))

if (toImport.length === 0) {
toasts.add('No new datamaps to import', 'info')
orphanDatamaps.value = []
rescueScanned.value = false
return
}

// Build the full entries list (existing history + new) and persist.
const fullEntries: UploadHistoryEntry[] = [
...filesStore.files
.filter(f => f.kind === 'upload' && f.status === 'complete' && f.address)
.map(f => ({
name: f.name,
size_bytes: f.size_bytes,
address: f.address!,
cost: f.cost ?? null,
uploaded_at: f.date,
data_map_file: f.data_map_file ?? null,
})),
...toImport,
]
await invoke('save_upload_history', { entries: fullEntries })

// Refresh the store so the Files page picks them up immediately.
filesStore.historyLoaded = false
filesStore.files = filesStore.files.filter(f => f.kind !== 'upload' || f.status !== 'complete')
await filesStore.loadHistory()

toasts.add(`Imported ${toImport.length} datamap${toImport.length === 1 ? '' : 's'}`, 'success')
orphanDatamaps.value = []
rescueScanned.value = false
} catch (e: any) {
toasts.add(`Import failed: ${e.message ?? e}`, 'error')
} finally {
rescueImporting.value = false
}
}

async function sha256Hex(text: string): Promise<string> {
const bytes = new TextEncoder().encode(text)
const digest = await crypto.subtle.digest('SHA-256', bytes)
const hex = Array.from(new Uint8Array(digest))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
return `0x${hex}`
}

function formatShortDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
} catch {
return iso
}
}

</script>
Loading
Loading