Legend:
Page
Library
Module
Module type
Parameter
Class
Class type
Source
Source file file_browser_widget.ml
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850(*****************************************************************************)(* *)(* SPDX-License-Identifier: MIT *)(* Copyright (c) 2025 Nomadic Labs <contact@nomadic-labs.com> *)(* Copyright (c) 2026 Mathias Bourgoin <mathias.bourgoin@atacama.tech> *)(* *)(*****************************************************************************)[@@@warning"-32-34-37-69"]typeentry={name:string;is_dir:bool}(* Cache for directory listings and writable status to avoid repeated filesystem calls *)typecache={mutablecached_path:string;mutablecached_entries:entrylist;mutablecached_writable:(string,bool)Hashtbl.t;mutablecached_show_hidden:bool;}letmake_cache()={cached_path="";cached_entries=[];cached_writable=Hashtbl.create32;cached_show_hidden=false;}(* Global cache - invalidated when path changes *)letcache=make_cache()letinvalidate_cache()=cache.cached_path<-"";cache.cached_entries<-[];Hashtbl.clearcache.cached_writabletypet={current_path:string;cursor:int;cancelled:bool;dirs_only:bool;require_writable:bool;select_dirs:bool;show_hidden:bool;(* whether to show hidden files/dirs starting with '.' *)(* New: direct path editing *)mode:mode;path_buffer:string;(* kept for compatibility; source of truth is textbox when present *)path_error:stringoption;pending_selection:stringoption;create_dir_on_enter:bool;textbox:Miaou_widgets_input.Textbox_widget.toption;history:stringlist;(* most-recent-first *)history_idx:intoption;(* index into history while editing *)}andmode=Browsing|EditingPathmoduleTextbox=Miaou_widgets_input.Textbox_widgetmoduleHelpers=Miaou_helpers.Helperslettextbox_create?(width=60)?(initial="")()=Textbox.create~width~initial()lettextbox_get_text=Textbox.get_textlettextbox_set_texttbtext=Textbox.set_texttbtextlettextbox_handle_keytb~key=Textbox.handle_keytb~keylettextbox_rendertb=Textbox.rendertb~focus:trueletkey_hintsw=lethidden_hint=ifw.show_hiddenthen"hide hidden"else"show hidden"in[("↑/↓","navigate");("PgUp/PgDn","page");("Space","select");("Enter","open");("Backspace","parent");("Tab","edit path");("h",hidden_hint);("n","new directory");("Esc","cancel");](* Global state to handle async updates from modal callbacks *)letpending_path_update:stringoptionref=refNone(* Function to apply any pending path updates *)letapply_pending_updatesbrowser=match!pending_path_updatewith|None->browser|Somepath->pending_path_update:=None;{browserwithcurrent_path=path;cursor=0}(* Function to schedule a path update for later application *)letschedule_path_updatepath=pending_path_update:=Somepathletrecnormalize_startp=letsys=Miaou_interfaces.System.require()intryifsys.file_existsp&&sys.is_directorypthen(* Prefer an absolute, canonical path when available so that navigating
up keeps working (e.g. avoid getting stuck at "./"). *)tryUnix.realpathpwith_->pelseletparent=Filename.dirnamepinifparent=pthen"/"elsenormalize_startparentwith_->"/"letopen_centered?(path="/")?(dirs_only=true)?(require_writable=true)?(select_dirs=true)?(show_hidden=false)()=letstart=normalize_startpathin{current_path=start;cursor=0;cancelled=false;dirs_only;require_writable;select_dirs;show_hidden;mode=Browsing;path_buffer="";path_error=None;pending_selection=None;create_dir_on_enter=false;textbox=None;history=[];history_idx=None;}letclamp=List_nav.clampletis_writablepath=matchHashtbl.find_optcache.cached_writablepathwith|Somev->v|None->letsys=Miaou_interfaces.System.require()inletresult=matchsys.probe_writable~pathwithOkb->b|Error_->falseinHashtbl.addcache.cached_writablepathresult;resultletrecnext_available_name~existing~prefixidx=letcandidate=ifidx=0thenprefixelsePrintf.sprintf"%s_%d"prefixidxinifList.exists(fun(e:entry)->e.name=candidate)existingthennext_available_name~existing~prefix(idx+1)elsecandidateletis_hiddenname=String.lengthname>0&&name.[0]='.'&&name<>".."&&name<>"."letlist_entriespath~dirs_only~show_hidden=letsys=Miaou_interfaces.System.require()inmatchsys.list_dirpathwith|Errore->Errore|Okitems->letitems=List.filter(funn->n<>".")itemsinletitems=List.sortString.compareitemsinletmapped=List.fold_right(funnacc->(* Filter hidden files unless show_hidden is true *)if(notshow_hidden)&&is_hiddennthenaccelseletfull=Filename.concatpathninletis_dir=trysys.is_directoryfullwith_->falseinifdirs_only&¬is_dirthenaccelse{name=n;is_dir}::acc)items[]inletdirs,files=List.partition(fune->e.is_dir)mappedinOk(dirs@files)(* Internal helper that returns empty list on error for backward compatibility *)letlist_entries_safepath~dirs_only~show_hidden=matchlist_entriespath~dirs_only~show_hiddenwith|Okentries->entries|Error_->[]letlist_entries_with_parentpath~dirs_only~show_hidden=(* Use cached entries if path and show_hidden match *)ifcache.cached_path=path&&cache.cached_show_hidden=show_hidden&&cache.cached_entries<>[]thencache.cached_entrieselsebegin(* Path or show_hidden changed - clear writable cache for fresh checks *)ifcache.cached_path<>paththenHashtbl.clearcache.cached_writable;letentries=list_entries_safepath~dirs_only~show_hiddeninletparent=Filename.dirnamepathinletresult=letwith_parent=ifparent=paththenentrieselse{name="..";is_dir=true}::entriesin(* Always include the current directory entry first so Enter selects it by default. *){name=".";is_dir=true}::with_parentin(* Update cache *)cache.cached_path<-path;cache.cached_entries<-result;cache.cached_show_hidden<-show_hidden;resultendletis_cancelledw=w.cancelledletreset_cancelledw={wwithcancelled=false}letget_current_pathw=w.current_pathletget_pending_selectionw=w.pending_selectionletget_selected_entryw=letentries=list_entries_with_parentw.current_path~dirs_only:w.dirs_only~show_hidden:w.show_hiddeninifentries=[]thenNoneelseletidx=clamp0(max0(List.lengthentries-1))w.cursorinList.nth_optentriesidxletget_selectionw=matchw.pending_selectionwith|Somep->Somep|None->(matchw.modewith|EditingPath->((* In edit mode, return the textbox content as the selection *)matchw.textboxwith|Sometb->letpath=textbox_get_texttbinifpath=""thenSomew.current_pathelseSomepath|None->Somew.current_path)|Browsing->(matchget_selected_entrywwith|None->Somew.current_path|Somee->lettarget=ife.name=".."thenFilename.dirnamew.current_pathelseife.name="."thenw.current_pathelseFilename.concatw.current_pathe.nameinife.is_dir&¬w.select_dirsthenNoneelseSometarget))letis_editingw=matchw.modewithEditingPath->true|Browsing->falseletcan_commitw=matchw.modewith|EditingPath->((* Allow commit only when we have a validated pending file selection *)matchw.pending_selectionwith|Some_->true|None->false)|Browsing->trueletcurrent_inputw=matchw.textboxwith|Sometb->textbox_get_texttb|None->ifw.path_buffer=""thenw.current_pathelsew.path_bufferletrechandle_keyw~key=(* Apply any pending path updates first *)letw=apply_pending_updateswinletselection_for_entrywe=lettarget=ife.name=".."thenFilename.dirnamew.current_pathelseife.name="."thenw.current_pathelseFilename.concatw.current_pathe.nameinife.is_dir&¬w.select_dirsthenNoneelseSometargetinletentries=list_entries_with_parentw.current_path~dirs_only:w.dirs_only~show_hidden:w.show_hiddeninlettotal=List.lengthentriesinmatchw.modewith|Browsing->(matchkeywith|"h"->(* Toggle hidden files *)invalidate_cache();{wwithshow_hidden=notw.show_hidden;cursor=0}|"Up"->{wwithcursor=List_nav.move_cursor~total~cursor:w.cursor~delta:(-1);}|"Down"->{wwithcursor=List_nav.move_cursor~total~cursor:w.cursor~delta:1;pending_selection=None;}|"PageUp"->{wwithcursor=List_nav.page_move~total~cursor:w.cursor~page_size:8~dir:`Up;pending_selection=None;}|"PageDown"->{wwithcursor=List_nav.page_move~total~cursor:w.cursor~page_size:8~dir:`Down;pending_selection=None;}|" "->(* Explicitly select current entry without navigating. *)letpending=matchList.nth_optentriesw.cursorwith|None->None|Somee->selection_for_entrywein{wwithpending_selection=pending}|"n"->ifnot(is_writablew.current_path)then{wwithpath_error=Some"Not writable"}elseletentries=list_entries_safew.current_path~dirs_only:false~show_hidden:trueinletsuggested=next_available_name~existing:entries~prefix:"new_directory"0inlettb=textbox_create~initial:suggested()in{wwithmode=EditingPath;textbox=Sometb;create_dir_on_enter=true;path_error=None;pending_selection=None;history_idx=None;}|"Shift-Space"->{wwithcursor=List_nav.page_move~total~cursor:w.cursor~page_size:8~dir:`Up;}|"Esc"->{wwithcancelled=true}|"Enter"->((* Navigate into subdirectories, select current dir or files *)matchList.nth_optentriesw.cursorwith|Someentrywhenentry.name=".."->letparent=Filename.dirnamew.current_pathin{wwithcurrent_path=parent;cursor=0;pending_selection=None;}|Someentrywhenentry.name="."->(* Select the current directory *){wwithpending_selection=selection_for_entrywentry}|Someentry->lettarget=Filename.concatw.current_pathentry.nameinletsys=Miaou_interfaces.System.require()inletis_dir=entry.is_dir||trysys.is_directorytargetwith_->falseinifis_dirthen(* Navigate into subdirectory *)letnew_path=normalize_starttargetin{wwithcurrent_path=new_path;cursor=0;path_error=None;pending_selection=None;}else(* Fallback: if is_directory failed, try to list it.
If we can list it, it's browseable, so treat as directory. *)letworks_as_dir=matchlist_entriestarget~dirs_only:false~show_hidden:w.show_hiddenwith|Ok_->true|Error_->falseinifworks_as_dirthen{wwithcurrent_path=target;(* Skip normalize_start as it might fail is_directory check *)cursor=0;path_error=None;pending_selection=None;}else(* Select the file *){wwithpending_selection=selection_for_entrywentry}|_->w)|"Left"|"Backspace"->letparent=Filename.dirnamew.current_pathin{wwithcurrent_path=parent;cursor=0}|"Tab"|"C-l"->(* Enter editing with textbox prefilled from current_path. *)lettb=textbox_create~initial:w.current_path~width:60()in{wwithmode=EditingPath;textbox=Sometb;path_error=None;pending_selection=None;history_idx=None;}|kwhenString.lengthk=1&&(k.[0]='/'||k.[0]='~')->lettb=textbox_create~initial:(w.current_path^k)~width:60()in{wwithmode=EditingPath;textbox=Sometb;path_error=None;pending_selection=None;history_idx=None;}|"WheelUp"->{wwithcursor=List_nav.move_cursor~total~cursor:w.cursor~delta:(-Miaou_helpers.Mouse.wheel_scroll_lines);}|"WheelDown"->{wwithcursor=List_nav.move_cursor~total~cursor:w.cursor~delta:Miaou_helpers.Mouse.wheel_scroll_lines;}|key->((* Check for mouse click to select entry *)matchMiaou_helpers.Mouse.parse_clickkeywith|Some{row;col=_}->(* Header is 1 line (path bar), so body starts at row 2.
row is 1-indexed, so item at row 2 is index 0. *)letheader_lines=1inletclicked_idx=row-header_lines-1inifclicked_idx>=0&&clicked_idx<totalthenletw={wwithcursor=clicked_idx}in(* Double-click activates entry (same as Enter) *)ifMiaou_helpers.Mouse.is_double_clickkeythenhandle_keyw~key:"Enter"elsewelsew|None->w))|EditingPath->(match(key,w.textbox)with|"Esc",_->{wwithmode=Browsing;path_error=None;pending_selection=None;create_dir_on_enter=false;history_idx=None;}|" ",Sometb->(* Space in edit mode: insert space, never set pending_selection *){wwithtextbox=Some(textbox_handle_keytb~key:" ")}|("Up"|"Down"),Sometb->letlen=List.lengthw.historyiniflen=0thenwelseletidx=match(key,w.history_idx)with|"Up",None->0|"Down",None->0|"Up",Somei->min(len-1)(i+1)|"Down",Somei->max0(i-1)|_->0inlettext=List.nthw.historyidxin{wwithhistory_idx=Someidx;textbox=Some(textbox_set_texttbtext);}|"Tab",Sometb->((* Completion forward - always include hidden files for tab completion *)letbuf=textbox_get_texttbinletdir=Filename.dirnamebufinletbase=Filename.basenamebufinletcandidates=list_entries_safedir~dirs_only:false~show_hidden:trueinletnames=List.map(fune->e.name)candidatesinletmatches=List.filter(funn->String.starts_with~prefix:basen)namesinletchoosename=letnewp=Filename.concatdirnameinletis_dir=List.exists(fune->e.name=name&&e.is_dir)candidatesinletnewp=ifis_dirthennewp^"/"elsenewpin{wwithtextbox=Some(textbox_set_texttbnewp);path_error=None;}inmatchmatcheswith|[]->w|[one]->chooseone|many->choose(List.hdmany))|"Shift-Tab",Sometb->((* Completion backward - always include hidden files for tab completion *)letbuf=textbox_get_texttbinletdir=Filename.dirnamebufinletbase=Filename.basenamebufinletcandidates=list_entries_safedir~dirs_only:false~show_hidden:trueinletnames=List.map(fune->e.name)candidatesinletmatches=List.filter(funn->String.starts_with~prefix:basen)namesinletchoosename=letnewp=Filename.concatdirnameinletis_dir=List.exists(fune->e.name=name&&e.is_dir)candidatesinletnewp=ifis_dirthennewp^"/"elsenewpin{wwithtextbox=Some(textbox_set_texttbnewp);path_error=None;}inmatchList.revmatcheswith[]->w|one::_->chooseone)|"Enter",Sometb->letsys=Miaou_interfaces.System.require()inletp=lets=textbox_get_texttbinifs=""thenw.current_pathelsesinletp=ifFilename.is_relativepthenFilename.concatw.current_pathpelsepinifw.create_dir_on_enterthenifsys.file_existspthen{wwithpath_error=Some"Already exists"}elsematchsys.mkdirpwith|Ok()->(* Invalidate cache after creating directory *)invalidate_cache();{wwithcurrent_path=p;cursor=0;mode=Browsing;path_error=None;pending_selection=None;history=(leth=List.filter(funx->x<>p)w.historyinp::h);history_idx=None;create_dir_on_enter=false;}|Errore->{wwithpath_error=Somee}elseletexists=sys.file_existspinifnotexiststhen{wwithpath_error=Some"Path not found"}elseletwritable_ok=(notw.require_writable)||is_writablepinifsys.is_directorypthenifwritable_okthen{wwithcurrent_path=p;cursor=0;mode=Browsing;path_error=None;pending_selection=None;history=(ifp=""thenw.historyelseleth=List.filter(funx->x<>p)w.historyinp::h);history_idx=None;}else{wwithpath_error=Some"Not writable"}elseifwritable_okthen{wwithpending_selection=Somep;path_error=None;history=(leth=List.filter(funx->x<>p)w.historyinp::h);history_idx=None;}else{wwithpath_error=Some"Not writable"}|_,Sometb->{wwithtextbox=Some(textbox_handle_keytb~key)}|_,None->w)letrender_with_sizew~focus:_~(size:LTerm_geom.size)=letw=apply_pending_updateswinletmoduleW=Miaou_widgets_display.Widgetsinlethuman_bytes(n:int64)=letopenInt64inletfx=Int64.to_floatxinletunits=[|"B";"KB";"MB";"GB";"TB"|]inletrecloopvu=ifv<1024L||u=Array.lengthunits-1thenPrintf.sprintf"%.1f%s"(fv)units.(u)elseloop(divv1024L)(u+1)inloopn0inletentries=list_entries_with_parentw.current_path~dirs_only:w.dirs_only~show_hidden:w.show_hiddeninlettotal=List.lengthentriesinletcursor=clamp0(iftotal=0then0elsetotal-1)w.cursorinletw={wwithcursor}inletrows_total=size.LTerm_geom.rowsin(* Width sizing: compute maximum content width and helpers. *)letmax_width=max10(size.LTerm_geom.cols-2)inlettruncates=letlen=W.visible_chars_countsiniflen<=max_widththenselseifmax_width<=1thenString.makemax_width'.'elseletcut=max0(max_width-1)inString.subs0cut^"."inletpad_to_widths=letv=W.visible_chars_countsinifv>=max_widththenselses^String.make(max_width-v)' 'inletshorten_path_topmax_len=ifmax_len<=5thenifString.lengthp<=max_lenthenpelseString.subp0(max0(max_len-1))^"…"elseifString.lengthp<=max_lenthenpelseletkeep=(max_len-1)/2inletfirst=String.subp0keepinletlast=String.subp(String.lengthp-keep)keepinfirst^"…"^lastin(* Path editor bar at the top *)letpath_bar=matchw.modewith|Browsing->letprefix="Path: ["inletsuffix="]"inletroom=max0(max_width-(String.lengthprefix+String.lengthsuffix))inletlabel=prefix^shorten_path_tow.current_pathroom^suffix|>truncate|>pad_to_widthin[label]|EditingPath->lettb=matchw.textboxwith|Somet->t|None->textbox_create~initial:w.current_path()inletprefix="Path: "inletrendered=textbox_rendertbinletline=prefix^rendered|>truncate|>pad_to_widthinletline=matchw.path_errorwith|None->W.themed_selectionline|Somemsg->W.themed_error(line^" "^msg)in[line]inlethidden_hint=ifw.show_hiddenthen"hide hidden"else"show hidden"inletheader=path_barin(* Vertical sizing: render exactly [rows_total] lines so parent frames don't
crop in a way that desynchronizes cursor and viewport. *)letfooter_pairs=[("↑/↓","navigate");("PgUp/PgDn","page");("Space","select");("Enter","open");("Esc","cancel");("Backspace","parent");("Tab","edit path");("h",hidden_hint);("n","new directory");]inletfooter_controls=W.footer_hints_wrapped_capped~cols:size.cols~max_lines:2footer_pairsinletfooter_hint_lines=String.split_on_char'\n'footer_controlsinletheader_lines=List.lengthheaderinletfooter_lines=2+List.lengthfooter_hint_lines(* blank + status + footer hint lines *)inletbody_capacity=max0(rows_total-header_lines-footer_lines)inletmax_shown=mintotalbody_capacityinletstart=iftotal<=max_shown||max_shown<=0then0elseletmax_start=max0(total-max_shown)inletdesired=w.cursor-(max_shown-1)inmax0(mindesiredmax_start)inletslice=entries|>List.mapi(funiv->(i,v))|>List.filter(fun(i,_)->i>=start&&i<start+max_shown)inletbody=letsys=Miaou_interfaces.System.require()inList.map(fun(i,e)->letfull=ife.name=".."thenFilename.dirnamew.current_pathelseFilename.concatw.current_pathe.nameinletsize_suffix=ife.name=".."then""elseife.is_dirthen"/"elsematchsys.get_disk_usage~path:fullwith|Okbytes->" ("^human_bytesbytes^")"|Error_->""inletname_for_display=matche.namewith"."->w.current_path|_->e.nameinletplain=ife.is_dirthenname_for_display^"/"elsename_for_display^size_suffixinletclipped=plain|>truncatein(* Apply icon prefix and per-extension foreground colour. The
selection highlight applied below still wins for the cursor row. *)letcolored=File_icons.decorate~name:e.name~is_dir:e.is_dirclippedinletlabel=ifw.mode=EditingPaththenW.themed_mutedcoloredelsecoloredin(* Only show selection highlight in Browsing mode *)ifw.mode=Browsing&&i=w.cursorthenW.themed_selectionlabelelselabel)sliceinletbody=ifList.lengthbody>=body_capacitythenbodyelsebody@List.init(body_capacity-List.lengthbody)(fun_->"")in(* Show selectable status near the bottom; footer shows action keys like Enter and n. *)letselectable=matchList.nth_optentriesw.cursorwith|None->is_writablew.current_path|Somee->letfull=Filename.concatw.current_pathe.nameinis_writablefullinletstatus=letbase=ifselectablethenW.themed_success"Selectable"elseW.themed_warning"Not writable"in(* If current item is a file, append size hint to status for quick preview *)lets=matchList.nth_optentriesw.cursorwith|Someewhennote.is_dir->(letfull=Filename.concatw.current_pathe.nameinmatch(Miaou_interfaces.System.require()).get_disk_usage~path:fullwith|Okbytes->base^W.themed_muted(" • size "^human_bytesbytes)|Error_->base)|_->baseins|>truncate|>pad_to_widthinletpadded_footer_hints=footer_hint_lines|>List.map(funl->W.themed_muted(truncatel|>pad_to_width))inletsections=header@body@(""::status::padded_footer_hints)inHelpers.concat_linessectionsletrenderw~focus=letdefault_size:LTerm_geom.size={rows=24;cols=80}inrender_with_sizew~focus~size:default_sizeletmkdir_and_cdbrowserdirname=letsys=Miaou_interfaces.System.require()inletnew_path=Filename.concatbrowser.current_pathdirnameinmatchsys.mkdirnew_pathwith|Errore->Errore|Ok()->((* Invalidate cache after creating directory *)invalidate_cache();(* Verify we can list the new directory *)matchlist_entriesnew_path~dirs_only:browser.dirs_only~show_hidden:browser.show_hiddenwith|Errore->Errore|Ok_entries->(* Schedule the path update instead of returning it directly *)schedule_path_updatenew_path;(* Return the original browser state - the update will be applied later *)Ok(browser,true))let()=Miaou_registry.register~name:"file_browser"~mli:[%blob"file_browser_widget.mli"]()