@@ -59,12 +59,12 @@ type scanModel struct {
5959// --- Messages ---
6060
6161type fileUploadedMsg struct {
62- index int
63- sha256 string
64- size int64
65- err error
66- isArchive bool
67- childHashes []string
62+ index int
63+ sha256 string
64+ size int64
65+ err error
66+ isArchive bool
67+ children []entity. DerivedFile
6868}
6969
7070type fileScanStatusMsg struct {
@@ -74,9 +74,11 @@ type fileScanStatusMsg struct {
7474}
7575
7676type fileScanDoneMsg struct {
77- index int
78- summary scanSummary
79- err error
77+ index int
78+ summary scanSummary
79+ isArchive bool
80+ children []entity.DerivedFile
81+ err error
8082}
8183
8284// --- Commands (async I/O) ---
@@ -99,16 +101,13 @@ func uploadFileCmd(index int, web webapi.Service, filename, token string) tea.Cm
99101 if err != nil {
100102 return fileUploadedMsg {index : index , err : fmt .Errorf ("upload: %w" , err )}
101103 }
102- // Use the SHA256 from the server response. For single-file ZIPs,
103- // the server extracts the file and returns the child's hash, not
104- // the ZIP's hash. For multi-file ZIPs, the server returns the
105- // archive doc with child hashes so we can track them individually.
104+ // Don't use is_archive from the upload response: the file hasn't been
105+ // processed yet, so the field is always false at this point. Archive
106+ // detection happens in fetchResultCmd once the parent scan completes.
106107 return fileUploadedMsg {
107- index : index ,
108- sha256 : file .SHA256 ,
109- size : file .Size ,
110- isArchive : file .IsArchive ,
111- childHashes : derivedHashes (file .DerivedFiles ),
108+ index : index ,
109+ sha256 : file .SHA256 ,
110+ size : file .Size ,
112111 }
113112 } else if forceRescanFlag {
114113 // Fetch the existing file to check if it's an archive.
@@ -125,11 +124,11 @@ func uploadFileCmd(index int, web webapi.Service, filename, token string) tea.Cm
125124 }
126125 }
127126 return fileUploadedMsg {
128- index : index ,
129- sha256 : sha256 ,
130- size : file .Size ,
131- isArchive : true ,
132- childHashes : derivedHashes ( file .DerivedFiles ) ,
127+ index : index ,
128+ sha256 : sha256 ,
129+ size : file .Size ,
130+ isArchive : true ,
131+ children : file .DerivedFiles ,
133132 }
134133 }
135134
@@ -159,8 +158,12 @@ func fetchResultCmd(index int, web webapi.Service, sha256 string) tea.Cmd {
159158 if err := web .GetFile (sha256 , & file ); err != nil {
160159 return fileScanDoneMsg {index : index , err : fmt .Errorf ("get file report: %w" , err )}
161160 }
162- summary := buildScanSummary (file )
163- return fileScanDoneMsg {index : index , summary : summary }
161+ return fileScanDoneMsg {
162+ index : index ,
163+ summary : buildScanSummary (file ),
164+ isArchive : file .IsArchive ,
165+ children : file .DerivedFiles ,
166+ }
164167 }
165168}
166169
@@ -189,11 +192,11 @@ func rescanFileCmd(index int, web webapi.Service, sha256, token string) tea.Cmd
189192 }
190193 }
191194 return fileUploadedMsg {
192- index : index ,
193- sha256 : sha256 ,
194- size : file .Size ,
195- isArchive : true ,
196- childHashes : derivedHashes ( file .DerivedFiles ) ,
195+ index : index ,
196+ sha256 : sha256 ,
197+ size : file .Size ,
198+ isArchive : true ,
199+ children : file .DerivedFiles ,
197200 }
198201 }
199202
@@ -307,27 +310,27 @@ func (m scanModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
307310 }
308311 m .files [i ].sha256 = msg .sha256
309312
310- if msg .isArchive && len (msg .childHashes ) > 0 {
313+ if msg .isArchive && len (msg .children ) > 0 {
311314 // Archive container: poll parent for completion and track children.
312315 m .files [i ].state = stateScanning
313316 m .files [i ].isArchive = true
314- m .files [i ].childCount = len (msg .childHashes )
317+ m .files [i ].childCount = len (msg .children )
315318 m .files [i ].size = msg .size
316319 cmds = append (cmds , pollStatusCmd (i , m .web , msg .sha256 ))
317320
318321 archiveName := filepath .Base (m .files [i ].filename )
319- for _ , childHash := range msg .childHashes {
322+ for _ , df := range msg .children {
320323 s := spinner .New ()
321324 s .Spinner = spinner .Dot
322325 m .files = append (m .files , fileRow {
323- filename : archiveName + "/" + truncSha ( childHash ),
324- sha256 : childHash ,
326+ filename : archiveName + "/" + childDisplayName ( df ),
327+ sha256 : df . SHA256 ,
325328 state : stateScanning ,
326329 spinner : s ,
327330 })
328331 childIdx := len (m .files ) - 1
329332 cmds = append (cmds ,
330- pollStatusCmd (childIdx , m .web , childHash ),
333+ pollStatusCmd (childIdx , m .web , df . SHA256 ),
331334 m .files [childIdx ].spinner .Tick ,
332335 )
333336 }
@@ -364,9 +367,33 @@ func (m scanModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
364367 } else {
365368 m .files [i ].state = stateDone
366369 m .files [i ].result = & msg .summary
370+ // Late archive detection: for new uploads is_archive is false at
371+ // upload time and only becomes true once the backend processes the
372+ // file. The rescan/forceRescan paths pre-populate isArchive via
373+ // fileUploadedMsg, so skip them here to avoid adding duplicate rows.
374+ if msg .isArchive && ! m .files [i ].isArchive && len (msg .children ) > 0 {
375+ m .files [i ].isArchive = true
376+ m .files [i ].childCount = len (msg .children )
377+ archiveName := filepath .Base (m .files [i ].filename )
378+ for _ , df := range msg .children {
379+ s := spinner .New ()
380+ s .Spinner = spinner .Dot
381+ m .files = append (m .files , fileRow {
382+ filename : archiveName + "/" + childDisplayName (df ),
383+ sha256 : df .SHA256 ,
384+ state : stateScanning ,
385+ spinner : s ,
386+ })
387+ childIdx := len (m .files ) - 1
388+ cmds = append (cmds ,
389+ pollStatusCmd (childIdx , m .web , df .SHA256 ),
390+ m .files [childIdx ].spinner .Tick ,
391+ )
392+ }
393+ }
367394 }
368- cmd := m .maybeQuitOrNext ()
369- return m , cmd
395+ cmds = append ( cmds , m .maybeQuitOrNext () )
396+ return m , tea . Batch ( cmds ... )
370397 }
371398
372399 return m , tea .Batch (cmds ... )
@@ -470,10 +497,9 @@ func (m scanModel) View() string {
470497 if m .isRescan {
471498 label = " Rescanning "
472499 }
473- s += f .spinner .View () + styleLabel .Render (label ) + name + " ...\n "
500+ s += f .spinner .View () + styleLabel .Render (label ) + displayName ( name ) + " ...\n "
474501 case stateScanning :
475- sha := truncSha (f .sha256 )
476- s += f .spinner .View () + styleLabel .Render (" Scanning " ) + name + " " + styleDim .Render (sha ) + "\n "
502+ s += f .spinner .View () + styleLabel .Render (" Scanning " ) + displayName (name ) + " " + styleDim .Render (f .sha256 ) + "\n "
477503 }
478504 }
479505
@@ -486,10 +512,13 @@ func (m scanModel) View() string {
486512 name := filepath .Base (f .filename )
487513 switch f .state {
488514 case stateDone :
489- sha := truncSha (f .sha256 )
490- line := styleSuccess .Render ("✓" ) + " " + name + " " + styleDim .Render (sha )
515+ line := styleSuccess .Render ("✓" ) + " " + displayName (name ) + " " + styleDim .Render (f .sha256 )
491516 if f .isArchive {
492- line += " " + styleDim .Render (formatSize (f .size ))
517+ size := f .size
518+ if f .result != nil {
519+ size = f .result .Size
520+ }
521+ line += " " + styleDim .Render (formatSize (size ))
493522 line += " " + styleLabel .Render (fmt .Sprintf ("archive (%d files)" , f .childCount ))
494523 } else if f .result != nil {
495524 line += " " + styleDim .Render (formatSize (f .result .Size ))
@@ -509,7 +538,7 @@ func (m scanModel) View() string {
509538 }
510539 doneRows = append (doneRows , doneRow {line })
511540 case stateError :
512- line := styleError .Render ("✗" ) + " " + name + " " + styleError .Render (f .err .Error ())
541+ line := styleError .Render ("✗" ) + " " + displayName ( name ) + " " + styleError .Render (f .err .Error ())
513542 doneRows = append (doneRows , doneRow {line })
514543 }
515544 }
@@ -546,12 +575,36 @@ func truncSha(sha string) string {
546575 return sha
547576}
548577
549- func derivedHashes (files []entity.DerivedFile ) []string {
550- hashes := make ([]string , len (files ))
551- for i , f := range files {
552- hashes [i ] = f .SHA256
578+ // looksLikeHash returns true if s is a hex string of a common hash length
579+ // (MD5=32, SHA1=40, SHA256=64).
580+ func looksLikeHash (s string ) bool {
581+ if len (s ) != 32 && len (s ) != 40 && len (s ) != 64 {
582+ return false
583+ }
584+ for _ , c := range s {
585+ if ! ((c >= '0' && c <= '9' ) || (c >= 'a' && c <= 'f' ) || (c >= 'A' && c <= 'F' )) {
586+ return false
587+ }
588+ }
589+ return true
590+ }
591+
592+ // displayName returns the name as-is, unless it looks like a hash, in which
593+ // case it is truncated to the first 12 characters.
594+ func displayName (name string ) string {
595+ if looksLikeHash (name ) {
596+ return truncSha (name )
597+ }
598+ return name
599+ }
600+
601+ // childDisplayName returns the archive entry name when available, falling back
602+ // to the truncated SHA256 so the row always has a meaningful label.
603+ func childDisplayName (df entity.DerivedFile ) string {
604+ if df .Name != "" {
605+ return df .Name
553606 }
554- return hashes
607+ return truncSha ( df . SHA256 )
555608}
556609
557610func renderEncryptionStatus (s * scanSummary ) string {
0 commit comments