local Y=require("yaru.core") local gfx=require("yaru.gfx") local ui=require("yaru.ui") local fs=require("filesystem") local unicode=require("unicode") local M={} local TRASH="/home/.local/share/Trash" local clip local function trashFiles()return fs.concat(TRASH,"files")end local function copyTree(src,dst) if fs.isDirectory(src)then local ok,err=fs.makeDirectory(dst) if not ok and not fs.isDirectory(dst)then return nil,err end for name in fs.list(src)do local r,e=copyTree(fs.concat(src,name),fs.concat(dst,name)) if not r then return nil,e end end return true end return fs.copy(src,dst) end local function moveTree(src,dst) local a,b=fs.get(src),fs.get(dst) if a and b and a.address==b.address and not fs.isLink(src)then local ok=fs.rename(src,dst) if ok then return true end end local ok,err=copyTree(src,dst) if not ok then return nil,err end return fs.remove(src) end local function freeName(dir,name) local p=fs.concat(dir,name) if not fs.exists(p)then return p end local base,ext=name:match("^(.-)(%.[^.]*)$") if not base or base==""then base,ext=name,""end for i=2,999 do p=fs.concat(dir,base.." ("..i..")"..ext) if not fs.exists(p)then return p end end end local function kindOf(name,dir) if dir then return"folder"end local ext=name:match("%.([%w]+)$") ext=ext and ext:lower() if ext=="lua"then return"program"end if ext=="pic"then return"image"end if ext=="cfg"or ext=="txt"or ext=="md"or ext=="log"or ext=="json"or not ext then return"text"end return"file" end local ICON={folder="▆",program="▶",text="≡",file="□",image="▣"} local BIGICON={folder="folder",program="luafile",text="text",file="file",image="viewer"} function M.open(path,action) local st={path="/home",hist={},fwd={},sel=0,top=0,hidden=false,entries={},filter=nil,view="grid"} local win local trashMode=false local function load() st.entries={} local dir=trashMode and trashFiles()or st.path if fs.isDirectory(dir)then for name in fs.list(dir)do local isDir=name:sub(-1)=="/" local n=isDir and name:sub(1,-2)or name if st.hidden or n:sub(1,1)~="."then local full=fs.concat(dir,n) st.entries[#st.entries+1]={name=n,dir=isDir,full=full,size=(not isDir)and fs.size(full)or nil, link=fs.isLink(full)} end end end table.sort(st.entries,function(a,b) if a.dir~=b.dir then return a.dir end return unicode.lower(a.name)0 then st.sel=1 end st.top=0 win.title=trashMode and"Trash"or(st.path=="/home"and"Home"or fs.name(st.path)or"/") local d=fs.get(dir) st.free=d and d.spaceTotal and(d.spaceTotal()-d.spaceUsed())or nil st.ro=d and d.isReadOnly and d.isReadOnly() end local function go(p,noHist) if p=="trash://"then if not noHist then st.hist[#st.hist+1]=trashMode and"trash://"or st.path;st.fwd={}end trashMode=true fs.makeDirectory(trashFiles()) else p=fs.canonical(p) if not fs.isDirectory(p)then return Y.notify("Files",p.." is not a folder")end if not noHist then st.hist[#st.hist+1]=trashMode and"trash://"or st.path;st.fwd={}end trashMode=false st.path=p end st.sel=0 load() end local function selected()return st.entries[st.sel]end local function curDir()return trashMode and trashFiles()or st.path end local function refreshAll() for _,w in ipairs(Y.windowsOf("files"))do if w.reload then w.reload()end end end local function trash(e) if trashMode then Y.dialog{title="Delete “"..e.name.."” permanently?",text="This cannot be undone.", buttons={{"Cancel"},{"Delete",function()fs.remove(e.full);fs.remove(fs.concat(TRASH,"info",e.name..".trashinfo"));refreshAll()end,"destructive"}}} return end fs.makeDirectory(trashFiles());fs.makeDirectory(fs.concat(TRASH,"info")) local dst=freeName(trashFiles(),e.name) local ok,err=moveTree(e.full,dst) if not ok then return Y.notify("Cannot move to Trash",tostring(err))end local f=io.open(fs.concat(TRASH,"info",fs.name(dst)..".trashinfo"),"w") if f then f:write(e.full);f:close()end Y.notify("“"..e.name.."” moved to trash",nil,{actionLabel="Undo",action=function() moveTree(dst,e.full);fs.remove(fs.concat(TRASH,"info",fs.name(dst)..".trashinfo"));refreshAll() end}) refreshAll() end local function restore(e) local info=fs.concat(TRASH,"info",e.name..".trashinfo") local f=io.open(info) local orig=f and f:read("*a");if f then f:close()end orig=orig and orig~=""and orig or fs.concat("/home",e.name) local ok,err=moveTree(e.full,freeName(fs.path(orig),fs.name(orig))) if not ok then return Y.notify("Cannot restore",tostring(err))end fs.remove(info) refreshAll() end local function paste() if not clip or trashMode then return end local n,errs=0,0 for _,p in ipairs(clip.paths)do local dst=freeName(st.path,fs.name(p)) local ok if(st.path.."/"):sub(1,#p+1)==p.."/"then ok=false elseif clip.mode=="cut"then ok=moveTree(p,dst)else ok=copyTree(p,dst)end if ok then n=n+1 else errs=errs+1 end end if clip.mode=="cut"then clip=nil end if errs>0 then Y.notify("Paste",errs.." item(s) could not be pasted")end refreshAll() end local function rename(e) Y.dialog{title="Rename",entry=e.name,buttons={{"Cancel"},{"Rename",function(txt) if not txt or txt==""or txt==e.name then return end if txt:find("/")then return Y.notify("Rename","Names cannot contain “/”")end local ok,err=moveTree(e.full,fs.concat(fs.path(e.full),txt)) if not ok then Y.notify("Cannot rename",tostring(err))end refreshAll() end,"suggested"}}} end local function newFolder() Y.dialog{title="New Folder",text="Folder name",entry="Untitled Folder",buttons={{"Cancel"},{"Create",function(txt) if not txt or txt==""then return end local ok,err=fs.makeDirectory(fs.concat(st.path,txt)) if not ok then Y.notify("Cannot create folder",tostring(err))end refreshAll() end,"suggested"}}} end local function newFile() Y.dialog{title="New Document",text="File name",entry="Untitled.txt",buttons={{"Cancel"},{"Create",function(txt) if not txt or txt==""then return end local p=fs.concat(st.path,txt) if not fs.exists(p)then local f=io.open(p,"w")if f then f:close()end end refreshAll() Y.launch("editor",p) end,"suggested"}}} end local function openEntry(e,how) if not e then return end if trashMode then return restore(e)end if e.dir and how~="browse"and fs.exists(fs.concat(e.full,"index.ocv"))then return Y.launch("player",e.full)end if e.dir then return go(e.full)end if e.name=="index.ocv"then return Y.launch("player",fs.path(e.full))end local k=kindOf(e.name) if how=="paint"then return Y.launch("paint",e.full)end if k=="image"and not how then return Y.launch("viewer",e.full)end if e.name:match("%.yms$")and not how then return Y.launch("composer",e.full)end if how=="edit"or(k=="text"and how~="run")then return Y.launch("editor",e.full)end if k=="program"or how=="run"then return Y.launch("terminal",e.full)end Y.launch("editor",e.full) end local function properties(e) local t={"Location: "..fs.path(e.full)} if e.dir then local n=0 for _ in fs.list(e.full)do n=n+1 end t[#t+1]=n.." item(s)" else t[#t+1]="Size: "..ui.size(e.size or 0)end local lm=fs.lastModified(e.full) if lm and lm>0 then t[#t+1]="Modified: "..os.date("!%Y-%m-%d %H:%M",lm//1000+math.floor((Y.cfg.tz or 0)*3600))end local d=fs.get(e.full) if d then t[#t+1]="Drive: "..(d.getLabel()or d.address:sub(1,8))..(d.isReadOnly()and" (read-only)"or"")end Y.dialog{title=e.name,text=table.concat(t,"\n"),buttons={{"Close"}}} end local function entryMenu(e,x,y) local items if trashMode then items={{"Restore From Trash",function()restore(e)end},{"Delete Permanently",function()trash(e)end}} else items={ {"Open",function()openEntry(e)end,key="Enter"}, } if not e.dir then items[#items+1]={"Open With Text Editor",function()openEntry(e,"edit")end} if kindOf(e.name)=="image"then items[#items+1]={"Open With Paint",function()openEntry(e,"paint")end}end items[#items+1]={"Run in Terminal",function()openEntry(e,"run")end} else if fs.exists(fs.concat(e.full,"index.ocv"))then items[1]={"Play in Media Player",function()Y.launch("player",e.full)end,key="Enter"} items[#items+1]={"Browse Folder",function()openEntry(e,"browse")end} end items[#items+1]={"Open in Terminal",function()Y.launch("terminal",nil,e.full)end} end items[#items+1]="-" items[#items+1]={"Cut",function()clip={mode="cut",paths={e.full}}end,key="Ctrl+X"} items[#items+1]={"Copy",function()clip={mode="copy",paths={e.full}}end,key="Ctrl+C"} items[#items+1]={"Paste",paste,key="Ctrl+V",disabled=not clip} items[#items+1]="-" items[#items+1]={"Rename…",function()rename(e)end,key="F2"} items[#items+1]={"Move to Trash",function()trash(e)end,key="Del"} items[#items+1]="-" items[#items+1]={"Properties",function()properties(e)end} end Y.menu(x,y,items) end local function bgMenu(x,y) if trashMode then return Y.menu(x,y,{{"Empty Trash",function()M.emptyTrash(refreshAll)end,disabled=#st.entries==0}}) end Y.menu(x,y,{ {"New Folder…",newFolder},{"New Document…",newFile},"-", {"Paste",paste,key="Ctrl+V",disabled=not clip},"-", {"Show Hidden Files",function()st.hidden=not st.hidden;load()end,check=st.hidden,key="Ctrl+H"}, {st.view=="grid"and"View as List"or"View as Grid",function()st.view=st.view=="grid"and"list"or"grid";st.top=0 end,key="Ctrl+1"}, {"Open in Terminal",function()Y.launch("terminal",nil,st.path)end}, }) end win=Y.newWindow{app="files",title="Files",w=92,h=28,minW=50,minH=10} win.reload=function()local s=st.sel;load();st.sel=math.min(s,#st.entries);Y.redraw(win)end local SIDE=18 function win.header(w,hw) local t=Y.theme ui.button(w,2,1,"‹",function()local p=table.remove(st.hist)if p then st.fwd[#st.fwd+1]=trashMode and"trash://"or st.path;go(p,true)end end,"flat",3) ui.button(w,5,1,"›",function()local p=table.remove(st.fwd)if p then st.hist[#st.hist+1]=trashMode and"trash://"or st.path;go(p,true)end end,"flat",3) local x=9 if trashMode then gfx.text(x,1,"Trash",t.headerFg) else local crumbs,acc,rest={},"",st.path if st.path=="/home"or st.path:sub(1,6)=="/home/"then crumbs[1]={"⌂ Home","/home"};acc="/home";rest=st.path:sub(6) else crumbs[1]={"/","/"}end for seg in rest:gmatch("[^/]+")do acc=acc.."/"..seg;crumbs[#crumbs+1]={seg,acc}end for i,c in ipairs(crumbs)do local label=c[1] if x+unicode.len(label)+3>hw-12 then break end local cur=i==#crumbs local p=c[2] x=x+ui.button(w,x,1,label,function()go(p)end,cur and nil or"flat")+1 if not cur then gfx.text(x-1,1,"›",t.dim)end end end ui.button(w,hw-10,1,"☰",function(_,lx,ly) bgMenu(win.x+hw-12,win.y+1) end,"flat",3) if trashMode then ui.button(w,hw-7,1,"Empty",function()M.emptyTrash(refreshAll)end,"destructive",7)end end function win.draw(w,cw,ch) local t=Y.theme gfx.fill(1,1,SIDE,ch,t.sidebar) local places={{"⌂ Home","/home"},{"▆ Programs","/bin"},{"▆ Libraries","/lib"},{"▆ Temporary","/tmp"}, {"▭ Computer","/"}} local y=2 local function place(label,p) local cur=(p=="trash://"and trashMode)or(not trashMode and p==st.path) if cur then ui.select(2,y,SIDE-2,t.dark and 0x454545 or 0xDEDDDA)end gfx.text(3,y,ui.trunc(label,SIDE-4),t.fg) ui.hit(w,1,y,SIDE,1,function()go(p)end) y=y+1 end for _,p in ipairs(places)do place(p[1],p[2])end place("▯ Trash","trash://") if Y.drives and#Y.drives>0 then y=y+1 gfx.text(2,y,"Devices",t.dim);y=y+1 for _,d in ipairs(Y.drives)do local path=d.path place("▭ "..d.label,path) gfx.text(SIDE-2,y-1,"⏏",t.dim) ui.hit(w,SIDE-2,y-1,2,1,function()fs.umount(path);Y.scanDrives();Y.redraw();if st.path:sub(1,#path)==path then go("/home")end end) end end local lx=SIDE+1 local lw=cw-SIDE gfx.fill(lx,1,lw,ch,t.view) local rows=ch-3 if st.view=="grid"then local CW,CH=14,7 local cols=math.max(1,(lw-2)//CW) local vis=math.max(1,(ch-2)//CH) st.cols=cols if st.sel>0 then local r=(st.sel-1)//cols if r=st.top+vis then st.top=r-vis+1 end end if#st.entries==0 then local s=trashMode and"Trash is Empty"or"Folder is Empty" gfx.text(lx+(lw-unicode.len(s))//2,ch//2,s,t.dim,t.view) end for i=st.top*cols+1,math.min(#st.entries,(st.top+vis)*cols)do local e=st.entries[i] local k=i-st.top*cols-1 local gx,gy=lx+1+(k%cols)*CW,2+(k//cols)*CH if i==st.sel then ui.round(gx,gy,CW-1,CH-1,t.dark and 0x454545 or 0xDEDDDA)end local bi=BIGICON[kindOf(e.name,e.dir)] if e.dir and e.video==nil then e.video=fs.exists(fs.concat(e.full,"index.ocv"))end if e.video then bi="video"end Y.drawIcon(gx+3,gy,bi) local name=ui.trunc(e.name,CW-2) local bg=i==st.sel and t.sel or nil local nx=gx+(CW-1-unicode.len(name))//2 if bg then ui.pill(nx-1,gy+4,unicode.len(name)+2,bg)end gfx.text(nx,gy+4,name,bg and t.selFg or t.fg) if e.link then gfx.text(gx+10,gy+3,"↗",t.dim)end local idx=i ui.hit(w,gx,gy,CW,CH-1,function(_,px,py,btn,dbl) st.sel=idx if btn==1 then entryMenu(st.entries[idx],w.x+px-1,w.y+py+1) elseif dbl then openEntry(st.entries[idx])end end) end ui.scrollbar(cw,2,ch-2,st.top,vis,math.ceil(#st.entries/cols)) rows=0 end local nameW=lw-16 if rows>0 then gfx.text(lx+2,1,"Name",t.dim,t.view)end if rows>0 then gfx.text(lx+nameW+2,1,"Size",t.dim,t.view)end if st.sel>0 then if st.sel-1=st.top+rows then st.top=st.sel-rows end end if#st.entries==0 and st.view~="grid"then local s=trashMode and"Trash is Empty"or"Folder is Empty" gfx.text(lx+(lw-unicode.len(s))//2,ch//2,s,t.dim,t.view) end for i=1,rows do local e=st.entries[st.top+i] if not e then break end local yy=1+i local sel=st.top+i==st.sel local bg=sel and t.sel or t.view local fg=sel and t.selFg or t.fg if sel then ui.select(lx+1,yy,lw-3,bg)end local k=kindOf(e.name,e.dir) gfx.text(lx+1,yy,ICON[k],sel and fg or(e.dir and 0xE9AD0C or k=="program"and t.success or t.dim),bg) gfx.text(lx+3,yy,ui.trunc(e.name..(e.link and" ↗"or""),nameW-2),fg,bg) local sz=e.dir and"—"or ui.size(e.size or 0) gfx.text(lx+lw-2-unicode.len(sz),yy,sz,sel and fg or t.dim,bg) local idx=st.top+i ui.hit(w,lx,yy,lw,1,function(_,px,py,btn,dbl) st.sel=idx if btn==1 then entryMenu(st.entries[idx],w.x+px-1,w.y+py+1) elseif dbl then openEntry(st.entries[idx])end end) end if rows>0 then ui.scrollbar(cw,2,rows,st.top,rows,#st.entries)end gfx.fill(lx,ch,lw,1,t.sidebar) local status=#st.entries.." items" if st.free then status=status..", free space "..ui.size(st.free)end if st.ro then status=status.." (read-only)"end if clip then status=status.." • "..#clip.paths.." item(s) on clipboard ("..clip.mode..")"end gfx.text(lx+1,ch,ui.trunc(status,lw-2),t.dim,t.sidebar) table.insert(w.hits,1,{lx,2,lw,ch-2,function(_,px,py,btn)if btn==1 then bgMenu(w.x+px-1,w.y+py+1)else st.sel=0 end end}) end function win.scroll(w,x,y,dir) if st.view=="grid"then st.top=math.max(0,math.min(math.ceil(#st.entries/(st.cols or 1))-1,st.top-dir))return end st.top=math.max(0,math.min(#st.entries-1,st.top-dir*3)) if st.sel>0 and(st.sel<=st.top or st.sel>st.top+w.h-4)then st.sel=st.top+1 end end function win.key(w,char,code) local K=Y.K local c=Y.mods.ctrl() local e=selected() local step=st.view=="grid"and(st.cols or 1)or 1 if code==K.down then st.sel=math.min(#st.entries,st.sel+step) elseif code==K.up then st.sel=math.max(1,st.sel-step) elseif st.view=="grid"and code==K.right then st.sel=math.min(#st.entries,st.sel+1) elseif st.view=="grid"and code==K.left and not Y.mods.alt()then st.sel=math.max(1,st.sel-1) elseif c and code==0x02 then st.view=st.view=="grid"and"list"or"grid";st.top=0 elseif code==K.pgdn then st.sel=math.min(#st.entries,st.sel+w.h-4) elseif code==K.pgup then st.sel=math.max(1,st.sel-(w.h-4)) elseif code==K.home then st.sel=math.min(1,#st.entries) elseif code==K["end"]then st.sel=#st.entries elseif code==K.enter then openEntry(e) elseif code==K.back or(Y.mods.alt()and code==K.up)then if not trashMode and st.path~="/"then go(fs.path(st.path))end elseif Y.mods.alt()and code==K.left then local p=table.remove(st.hist)if p then st.fwd[#st.fwd+1]=st.path;go(p,true)end elseif code==K.f2 then if e and not trashMode then rename(e)end elseif code==K.del then if e then trash(e)end elseif code==0x3F then load() elseif c and code==K.c then if e then clip={mode="copy",paths={e.full}}end elseif c and code==0x2D then if e then clip={mode="cut",paths={e.full}}end elseif c and code==K.v then paste() elseif c and code==K.h then st.hidden=not st.hidden;load() elseif c and code==K.l then Y.dialog{title="Go to Location",entry=st.path,buttons={{"Cancel"},{"Go",function(p)if p and p~=""then go(p)end end,"suggested"}}} elseif c and Y.mods.shift()and code==0x31 then newFolder() elseif char and char>32 and not c then local ch=unicode.lower(unicode.char(char)) for i=1,#st.entries do local j=(st.sel+i-1)%#st.entries+1 if unicode.lower(unicode.sub(st.entries[j].name,1,1))==ch then st.sel=j break end end end end go(path or"/home",true) if action=="newfolder"then newFolder()end return win end function M.emptyTrash(done) Y.dialog{title="Empty all items from Trash?",text="All items in the Trash will be permanently deleted.", buttons={{"Cancel"},{"Empty Trash",function() fs.remove(fs.concat(TRASH,"files"));fs.remove(fs.concat(TRASH,"info")) fs.makeDirectory(fs.concat(TRASH,"files")) if done then done()end end,"destructive"}}} end return M