diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e32572..c4dded6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -152,3 +152,8 @@ read or change the production filesystem. It reads the console document from that URL and serves its download fixture over loopback HTTP, verifying the ZIP bytes saved by Chromium. API writes stay mocked; native attachments bypass Playwright response interception. + +`node scripts/files-change-ui-audit.js URL KEY` also covers selected Trash +actions and readable labels, managed/read-only controls, unlock/relock, blocked +keyboard shortcuts, and the shared Kit menu’s disabled styles, click guards and +keyboard focus. Its file changes stay in synthetic in-memory fixtures. diff --git a/docker/setup/client/files.js b/docker/setup/client/files.js index 0166e42..9716f38 100644 --- a/docker/setup/client/files.js +++ b/docker/setup/client/files.js @@ -25,8 +25,9 @@ const T3FilesUI = (() => { const space=T3FilesSpaceUI.create({api,request:(...args)=>mutate(...args),context,refresh:()=>reload(),notifyJob,go,download:paths=>archives.download(paths),trash:rows=>trashRows(rows)}); const selectedPaths=()=>selectedRows().map(rowPath); const writeReason=()=>!places?.capabilities?.change?'':onRoot()&&places?.projects?.writable===false?'Read-only for t3':data?.managed&&unlockedRoot!==data.managed.root?'This folder is managed. Choose Edit anyway first.':where?.place==='trash'?'':!onRoot()&&!data?.writable?'Read-only for t3':''; - const writeAttrs=()=>writeReason()?` disabled title="${Kit.esc(writeReason())}"`:''; + const writeAttrs=(reason=writeReason())=>reason?` disabled title="${Kit.esc(reason)}"`:''; const scopeBusy=values=>values.some(value=>pending.has(value)); + const selectionReason=()=>writeReason()||(scopeBusy(selectedPaths())?'An action is already changing this selection.':''); const alive=()=>getRoute()==='files'&&!document.hidden&&active; const onRoot=()=>where?.place==='projects'&&where.path===roots.workspace; const atPlaceRoot=()=>where?.path===(where.place==='home'?roots.home:roots.workspace); @@ -39,7 +40,7 @@ const T3FilesUI = (() => { const button=(label,command,{glyph,cls='',path:target,attrs=''}={})=>html``; const iconButton=(label,command,glyph,target,attrs='')=>html``; const paint=()=>{ - markup=body();render();const mount=document.getElementById('files-browser');if(mount){Kit.patch(mount,markup);for(const field of mount.querySelectorAll('[data-files-check]'))field.checked=!!current?.selection.has(field.dataset.path);const all=mount.querySelector('[data-files-select-all]');if(all&¤t){all.checked=visible().length>0&¤t.selection.size===visible().length;all.indeterminate=current.selection.size>0&&!all.checked;all.setAttribute('aria-label',all.checked?'Clear selection':'Select all items');}const rebuildField=mount.querySelector('[data-files-rebuildable]');if(rebuildField)rebuildField.checked=filters.rebuildable;const searchField=mount.querySelector('#files-search');if(searchField&&searchField.value!==query)searchField.value=query;} + markup=body();render();const mount=document.getElementById('files-browser');if(mount){Kit.patch(mount,markup);for(const field of mount.querySelectorAll('[data-files-check]'))field.checked=!!current?.selection.has(field.dataset.path);for(const all of mount.querySelectorAll('[data-files-select-all]'))if(current){all.checked=visible().length>0&¤t.selection.size===visible().length;all.indeterminate=current.selection.size>0&&!all.checked;all.setAttribute('aria-label',all.checked?'Clear selection':'Select all items');}const rebuildField=mount.querySelector('[data-files-rebuildable]');if(rebuildField)rebuildField.checked=filters.rebuildable;const searchField=mount.querySelector('#files-search');if(searchField&&searchField.value!==query)searchField.value=query;} if(sheet&&details)sheet.render(); }; const saveRecent=value=>{ @@ -129,7 +130,28 @@ const T3FilesUI = (() => { }; const stateView=(code,message,retry='refresh')=>{const state=F.state(code);return html`
${icon(state.icon)}

${state.title}

${message||state.copy}

${['missing','no-access'].includes(code)?button('Open parent folder','open',{glyph:'arrow-up',path:F.parent(where?.path||roots.workspace)}):code==='failed'?button('Try again',retry,{glyph:'refresh-cw'}):code==='search'?html`${button('Clear search','clear-search')}${!filters.rebuildable?button('Search rebuildable folders too','search-rebuild',{glyph:'refresh-cw'}):''}${button(where?.place==='home'?'Search all Home':'Search all projects','search-widen',{glyph:'folder'})}`:code==='projects'?button('Go to folder…','goto',{glyph:'folder-open'}):''}
`;}; const footer=()=>{const measured=onRoot()?data?.measuredAt||places?.measuredAt:data?.measuredAt;return html`
${count(data?.total??visible().length,onRoot()?'project':'item')}${Number.isFinite(sizeTotal())?html`·${bytes(sizeTotal())}`:''}${data?.counts?.hidden?html`·${data.counts.hidden} hidden${button(hidden()?'Hide':'Show','hidden',{cls:'tc-btn--link'})}`:''}${measuring.has(where?.path)?html`Starting measurement…`:jobs.some(row=>row.state==='running'&&row.path===where?.path)?html`Measuring ${F.basename(where.path)} ${Number.isFinite(jobs.find(row=>row.state==='running'&&row.path===where.path).done)?' · '+count(jobs.find(row=>row.state==='running'&&row.path===where.path).done,'file'):''}`:html`${measured?'Measured '+M.relTime(measured,Date.now()):'Not measured yet'}${button('Measure again','measure',{glyph:'refresh-cw',cls:'tc-btn--ghost tc-btn--xs',attrs:loading?' disabled':''})}`}
`;}; - const selectionBar=()=>html`
${count(current.selection.size,'selected item')}${bytes(F.totalOf(selectedRows()))}
${places?.capabilities?.archives?html`${button('Download…','download',{glyph:'download'})}${button('Compress','compress',{glyph:'archive',attrs:writeAttrs()})}`:''}${selectedRows().length===1?button('Details','details',{glyph:'panel-right',path:rowPath(selectedRows()[0])}):''}${places?.capabilities?.change?html`${button('Move to…','move',{glyph:'folder-input',attrs:writeAttrs()})}${button('Move to Trash','trash',{glyph:'trash-2',cls:'tc-btn--danger',attrs:writeAttrs()})}`:''}${iconButton('More selection actions','selection-menu','ellipsis')}${iconButton('Clear selection','clear-selection','x')}
`; + const selectionBar=()=>{ + const trash=where.place==='trash'; + return html`
+ +
${count(current.selection.size,'selected item')}${bytes(F.totalOf(selectedRows()))}
+
${trash?html`${button('Restore','restore',{glyph:'undo-2'})}${button('Remove selected…','empty-selected',{glyph:'trash-2',cls:'tc-btn--danger'})}`:html` + ${places?.capabilities?.archives?html`${button('Download…','download',{glyph:'download'})}${button('Compress','compress',{glyph:'archive',attrs:writeAttrs(selectionReason())})}`:''} + ${selectedRows().length===1?button('Details','details',{glyph:'panel-right',path:rowPath(selectedRows()[0])}):''} + ${places?.capabilities?.change?html`${button('Move to…','move',{glyph:'folder-input',attrs:writeAttrs(selectionReason())})}${button('Move to Trash','trash',{glyph:'trash-2',cls:'tc-btn--danger',attrs:writeAttrs(selectionReason())})}`:''} + ${iconButton('More selection actions','selection-menu','ellipsis')}`}${iconButton('Clear selection','clear-selection','x')} +
`; + }; + const phoneSelection=()=>{ + if(!PHONE.matches||!selectMode)return ''; + const trash=where.place==='trash',attrs=current.selection.size?writeAttrs(selectionReason()):' disabled'; + return html`
+
${current.selection.size} selected${button('Done','select-mode')}
+
${trash?html` + ${button('Restore','restore',{glyph:'undo-2',attrs})}${button('Remove…','empty-selected',{glyph:'trash-2',cls:'tc-btn--danger',attrs:attrs+' data-danger aria-label="Remove selected…"'})}`:html` + ${places?.capabilities?.change?html`${places?.capabilities?.archives?button('Download','download',{glyph:'download',attrs:current.selection.size?'':' disabled'}):''}${button('Move','move',{glyph:'folder-input',attrs})}${button('Trash','trash',{glyph:'trash-2',attrs:attrs+' data-danger'})}`:button('Copy paths','copy-selected',{glyph:'copy',attrs:current.selection.size?'':' disabled'})} + ${button('More','selection-menu',{glyph:'ellipsis',attrs:' aria-label="More selection actions"'})}`}
`; + }; const sortButton=(label,sort)=>html``; const marked=name=>{const index=query?name.toLocaleLowerCase().indexOf(query.toLocaleLowerCase()):-1;return index<0?name:html`${name.slice(0,index)}${name.slice(index,index+query.length)}${name.slice(index+query.length)}`;}; const rowMarkup=(row,index,max)=>{ @@ -216,7 +238,7 @@ const T3FilesUI = (() => { return html`${toolbar()}
${places?.capabilities?.space&&places.disk.usedPercent>=90?html`
${icon('triangle-alert')}
This disk is nearly full${bytes(places.disk.availableBytes)} free of ${bytes(places.disk.totalBytes)}. Make room before uploads or builds stop.
${button('Sizes','sizes-for',{glyph:'chart-bar',path:where.place==='trash'?roots.workspace:where.path})}${button('Free up space…','clean',{glyph:'eraser',path:roots.workspace})}${places.trash.count?button('Empty Trash…','empty-trash',{glyph:'trash-2'}):''}
`:''}${summary()}${where.place==='trash'&&data?html`
${icon('info')}
Items in Trash still use ${bytes(data.bytes)}Each is removed 7 days after it arrives. If the disk passes 90% full, the oldest go first.
`:''}${data?.managed?html`
${icon(unlockedRoot===data.managed.root?'lock-open':'lock')}
${unlockedRoot===data.managed.root?'Editing is unlocked in '+F.basename(data.managed.root)+' until you leave it':data.managed.title}${data.managed.body}
${places?.capabilities?.change?button(unlockedRoot===data.managed.root?'Lock again':'Edit anyway…',unlockedRoot===data.managed.root?'lock':'unlock',{glyph:unlockedRoot===data.managed.root?'lock':'lock-open'}):''}Open ${data.managed.pages[0]==='toolchains'?'Toolchains':data.managed.pages[0]==='agents'?'Agents':'Storage'}
`:data&&!data.writable&&!onRoot()&&where.place!=='trash'?html`
${icon('eye')}
You can look and download hereThis folder is read-only for t3.
`:''} ${query&&!onRoot()&&where.place!=='trash'?searchFilters():''}${searching?html`
Searching names in this folder…
`:searchData?html`
${count(searchData.results.length,'match')}${Number.isFinite(searchData.searched)?' · '+count(searchData.searched,'entry')+' searched':''}${searchData.skippedRebuildable?' · '+count(searchData.skippedRebuildable,'rebuildable folder')+' skipped':''}${searchData.truncated?' · Search reached its limit; narrow the name or open a smaller folder.':''}${searchData.partial?' · Some folders could not be read.':''}${button('Clear','clear-search',{cls:'tc-btn--link'})}
`:''} ${searchError?html`
${stateView('failed',searchError,'search-retry')}
`:error?html`
${stateView(error.code,error.message)}
`:loading&&!data?html`
${Array.from({length:7},()=>html`
`)}
`:html`
${current?.view==='sizes'&&where.place!=='trash'&&!query?sizesView():list()}${detailPanel()}
`} - ${PHONE.matches&&places?.capabilities?.change&&!selectMode&&where.place!=='trash'?html``:''}${transfers.content()}${dropTarget!==null&&dropTarget===where.path?html`
${icon('upload')}Upload into ${F.basename(dropTarget)}Files and folders keep their structure.
`:''}${PHONE.matches&&selectMode?html`
${current.selection.size} selected${places?.capabilities?.change?html`${places?.capabilities?.archives?button('Download','download',{glyph:'download',attrs:current.selection.size?'':' disabled'}):''}${button('Move','move',{glyph:'folder-input',attrs:current.selection.size?'':' disabled'})}${button(where?.place==='trash'?'Restore':'Trash',where?.place==='trash'?'restore':'trash',{glyph:where?.place==='trash'?'undo-2':'trash-2',attrs:current.selection.size?'':' disabled'})}`:button('Copy paths','copy-selected',{glyph:'copy',attrs:current.selection.size?'':' disabled'})}${iconButton('More selection actions','selection-menu','ellipsis')}${button('Done','select-mode')}
`:''}
`; + ${PHONE.matches&&places?.capabilities?.change&&!selectMode&&where.place!=='trash'?html``:''}${transfers.content()}${dropTarget!==null&&dropTarget===where.path?html`
${icon('upload')}Upload into ${F.basename(dropTarget)}Files and folders keep their structure.
`:''}${phoneSelection()}`; }; const loadPreview=async()=>{ clearPreview();if(!details?.preview)return; @@ -309,7 +331,7 @@ const T3FilesUI = (() => { {sep:true},{label:'Copy path',icon:'copy',run:()=>Kit.copy(value,null,'Path copied')}, ...(places?.capabilities?.change?[{sep:true},{label:'Rename',icon:'text-cursor-input',disabled:blocked||rootScope||scope.length!==1,run:()=>beginRename(row)},{label:'Move to…',icon:'folder-input',disabled:blocked||rootScope,run:()=>dialogs.move(scope)},{label:'Duplicate',icon:'copy-plus',disabled:blocked,run:()=>duplicate(scope)},{sep:true},{label:'Move to Trash',icon:'trash-2',danger:true,disabled:blocked||rootScope,run:()=>trashRows(scope)},...(places?.capabilities?.space?[{label:'Delete permanently…',icon:'trash-2',danger:true,disabled:blocked||scope.some(row=>[roots.home,roots.workspace,'/'].includes(rowPath(row))),run:()=>space.remove(scope)}]:[]),...(data?.managed&&unlockedRoot!==data.managed.root?[{label:'Edit anyway…',icon:'lock-open',run:()=>unlock(data.managed)}]:[])]:[]), ...(PHONE.matches?[{label:'Select',icon:'check',run:()=>{selectMode=true;select(value);}}]:[]), - ],footer:row.tags?.includes('managed')?'Managed here. Use Agents, Updates or Storage to make changes.':null}); + ],footer:writeReason()||(scopeBusy(scope.map(rowPath))?'An action is already changing this selection.':null)}); }; const viewMenu=trigger=>Kit.menu(trigger||document.querySelector('[data-cmd="files.view-menu"]'),{label:'View files',items:[ ...(where?.place==='trash'&&places?.capabilities?.change?[{label:'Empty Trash…',danger:true,icon:'trash-2',run:()=>dialogs.empty(rows())},{sep:true}]:[]), @@ -423,21 +445,32 @@ const T3FilesUI = (() => { const duplicate=async selected=>{selected=selected.filter(Boolean);if(!selected.length||writeReason())return;const response=await mutate('move',{paths:selected.map(rowPath),dest:where.path,copy:true,duplicate:true});if(response.ok&&!response.data.job)Kit.toast('Duplicated '+M.plural(selected.length,'item'));}; const trashList=()=>{ const selected=visible(),max=Math.max(1,...selected.map(row=>row.bytes||0));if(!selected.length)return html`
${stateView(query?'search':'trash')}${footer()}
`; - return html`
${current.selection.size?html`
${current.selection.size} selected${button('Restore','restore',{glyph:'undo-2'})}${button('Remove selected…','empty-selected',{glyph:'trash-2',cls:'tc-btn--danger'})}${iconButton('Clear selection','clear-selection','x')}
`:''}
${PHONE.matches?'':html`
NameFromDeletedSizeRemovedRestore
`}${selected.slice(0,500).map((row,index)=>PHONE.matches?html`
${selectMode?html``:smallIcon(row.kind)}
${row.name}From ${row.fromDisplay} · ${M.relTime(row.deletedAt,Date.now())}${row.names?html`${row.names}`:''}
`:html`
${smallIcon(row.kind)}${row.name}${row.names?html`${row.names}`:row.reason==='replaced'?html`Replaced by an upload or move`:''}${row.fromDisplay}${M.relTime(row.deletedAt,Date.now())}${bytes(row.bytes)}${Number.isFinite(Date.parse(row.removeAt))?'In '+M.plural(Math.max(0,Math.ceil((Date.parse(row.removeAt)-Date.now())/86400000)),'day'):'After 7 days'}${button('Restore','restore',{glyph:'undo-2',path:row.path})}
`)}
`; + return html`
${!PHONE.matches&¤t.selection.size?selectionBar():''}
${PHONE.matches?'':html`
NameFromDeletedSizeRemovedRestore
`}${selected.slice(0,500).map((row,index)=>PHONE.matches?html`
${selectMode?html``:smallIcon(row.kind)}
${row.name}From ${row.fromDisplay} · ${M.relTime(row.deletedAt,Date.now())}${row.names?html`${row.names}`:''}
`:html`
${smallIcon(row.kind)}${row.name}${row.names?html`${row.names}`:row.reason==='replaced'?html`Replaced by an upload or move`:''}${row.fromDisplay}${M.relTime(row.deletedAt,Date.now())}${bytes(row.bytes)}${Number.isFinite(Date.parse(row.removeAt))?'In '+M.plural(Math.max(0,Math.ceil((Date.parse(row.removeAt)-Date.now())/86400000)),'day'):'After 7 days'}${button('Restore','restore',{glyph:'undo-2',path:row.path})}
`)}
`; }; const command=async(name,args={},trigger)=>{ if(getRoute()!=='files'||!where){go('files');for(let attempt=0;attempt<100&&(getRoute()!=='files'||!where||!places||!active);attempt++)await new Promise(resolve=>setTimeout(resolve,50));if(getRoute()!=='files'||!where||!places||!active)return;} if(onRoot()&&places?.projects?.workspaceIsRepo&&['new-folder','new-file','upload','upload-folder','upload-menu'].includes(name)){go('files/path/'+F.encode(roots.workspace));for(let attempt=0;attempt<100&&(where.place!=='path'||loading||!data);attempt++)await new Promise(resolve=>setTimeout(resolve,50));if(where.place!=='path'||loading||!data)return;} + if(where.place==='trash'&&['rename','move','duplicate','compress','extract','upload','upload-folder','upload-menu','new-folder','new-file','edit'].includes(name)){ + Kit.toast('Restore items before editing or moving them',{tone:'info'});return; + } + if(['rename','move','duplicate','trash','delete','compress','extract','upload','upload-folder','upload-menu','new-folder','new-file','edit'].includes(name)&&writeReason()){ + Kit.toast(writeReason(),{tone:'info',actions:data?.managed?[{label:'Edit anyway…',run:()=>unlock(data.managed)}]:[]});return; + } if(name==='new-folder'){beginFolder();return;} if(name==='rename')return beginRename(findRow(args.path||[...current.selection][0])); if(name==='rename-save')return saveRename(); if(name==='rename-cancel'){if(!rename?.pending){rename=null;paint();}return;} if(name==='compress'){const paths=args.paths||selectedPaths();if(paths.length&&!writeReason()){const result=await mutate('compress',{paths});if(result.ok&&result.data.job)notifyJob(result.data.job);}return;} if(name==='clean')return space.clean(args.path||roots.workspace); - if(name==='delete')return space.remove(args.path?[findRow(args.path)||{path:args.path}]:selectedRows()); + if(name==='delete')return where.place==='trash'?dialogs.empty(selectedRows()):space.remove(args.path?[findRow(args.path)||{path:args.path}]:selectedRows()); if(name==='extract')return archives.extract(args.path||current.panelPath); if(name==='archive-retry'){archiveInfo=null;return loadArchive(current.panelPath);} - if(name==='selection-menu')return Kit.menu(trigger,{label:'Selection actions',items:[{label:'Copy paths',icon:'copy',run:()=>command('copy-selected')},...(places?.capabilities?.archives?[{label:'Compress',icon:'archive',disabled:!!writeReason(),run:()=>command('compress')}]:[]),...(places?.capabilities?.space?[{sep:true},{label:'Delete permanently…',danger:true,icon:'trash-2',disabled:!!writeReason(),run:()=>space.remove(selectedRows())}]:[])]}); + if(name==='selection-menu')return Kit.menu(trigger,{label:'Selection actions',footer:selectionReason(),items:[ + {label:'Copy paths',icon:'copy',run:()=>command('copy-selected')}, + ...(places?.capabilities?.archives?[{label:'Compress',icon:'archive',disabled:!!selectionReason(),run:()=>command('compress')}]:[]), + ...(places?.capabilities?.space?[{sep:true},{label:'Delete permanently…',danger:true,icon:'trash-2',disabled:!!selectionReason(),run:()=>command('delete')}]:[]), + ...(data?.managed&&unlockedRoot!==data.managed.root?[{sep:true},{label:'Edit anyway…',icon:'lock-open',run:()=>unlock(data.managed)}]:[]), + ]}); if(name==='upload')return transfers.pick(false); if(name==='upload-folder')return transfers.pick(true); if(name==='upload-menu')return Kit.menu(trigger,{label:'Upload',items:[{label:'Upload files',icon:'upload',run:()=>transfers.pick(false)},{label:'Upload a folder',icon:'folder-up',run:()=>transfers.pick(true)},{sep:true},{label:'New file',icon:'file-plus',run:()=>newFile()}]}); @@ -517,16 +550,16 @@ const T3FilesUI = (() => { if(event.defaultPrevented||getRoute()!=='files'||Kit.top())return; if(event.target.id==='files-rename-name'){if(event.key==='Enter'){event.preventDefault();void saveRename();}else if(event.key==='Escape'&&!rename?.pending){event.preventDefault();rename=null;paint();}return;} if(Kit.isTyping(event.target))return; - if(places?.capabilities?.change&&event.key==='F2'){event.preventDefault();void beginRename(findRow([...current.selection][0]));return;} - if(places?.capabilities?.space&&['Delete','Backspace'].includes(event.key)&&event.shiftKey){event.preventDefault();space.remove(selectedRows());return;} - if(places?.capabilities?.change&&['Delete','Backspace'].includes(event.key)&&!event.shiftKey){event.preventDefault();void trashRows(selectedRows());return;} - if((event.metaKey||event.ctrlKey)&&event.key.toLowerCase()==='u'){event.preventDefault();transfers.pick(false);return;} + if(places?.capabilities?.change&&event.key==='F2'){event.preventDefault();void command('rename');return;} + if(places?.capabilities?.space&&['Delete','Backspace'].includes(event.key)&&event.shiftKey){event.preventDefault();void command('delete');return;} + if(places?.capabilities?.change&&['Delete','Backspace'].includes(event.key)&&!event.shiftKey){event.preventDefault();void command('trash');return;} + if((event.metaKey||event.ctrlKey)&&event.key.toLowerCase()==='u'){event.preventDefault();void command('upload');return;} const mod=event.metaKey||event.ctrlKey; if(event.key==='?'){event.preventDefault();keyboardHelp();return;} if((event.key==='ContextMenu'||event.key==='F10'&&event.shiftKey)&&event.target.closest('[data-files-row]')){event.preventDefault();const row=event.target.closest('[data-files-row]');menu(row.dataset.path,row);return;} if(mod&&!event.shiftKey&&event.key.toLowerCase()==='c'&¤t.selection.size&&!window.getSelection()?.toString()){event.preventDefault();void Kit.copy(selectedRows().map(rowPath).join('\n'),null,'Paths copied');return;} - if(mod&&event.key.toLowerCase()==='d'){event.preventDefault();void duplicate(selectedRows());return;} - if(mod&&event.shiftKey&&event.key.toLowerCase()==='m'){event.preventDefault();if(selectedRows().length)void dialogs.move(selectedRows());return;} + if(mod&&event.key.toLowerCase()==='d'){event.preventDefault();void command('duplicate');return;} + if(mod&&event.shiftKey&&event.key.toLowerCase()==='m'){event.preventDefault();if(selectedRows().length)void command('move');return;} if(mod&&event.key.toLowerCase()==='l'){event.preventDefault();goToFolder();return;} if(mod&&event.key.toLowerCase()==='a'){event.preventDefault();current.selection=new Set(visible().map(rowPath));bulkSelection=true;paint();return;} if(mod&&event.key==='ArrowUp'){event.preventDefault();navigate(F.parent(where.path));return;} @@ -553,7 +586,7 @@ const T3FilesUI = (() => { else if(event.key===' '&¤t.panelPath){event.preventDefault();void showDetails(current.panelPath);} }); Kit.bind('mod+l',goToFolder,()=>getRoute()==='files'); - Kit.bind('mod+u',()=>{if(!writeReason()&&where.place!=='trash')transfers.pick(false);},()=>getRoute()==='files'&&!!places?.capabilities?.change); + Kit.bind('mod+u',()=>void command('upload'),()=>getRoute()==='files'&&!!places?.capabilities?.change); const dropPath=event=>{const row=event.target.closest('[data-files-row]'),entry=row&&findRow(row.dataset.path);return entry?.kind==='folder'?entry.path:entry?.target?.kind==='folder'?entry.target.path:where.path;}; const dragKind=event=>event.dataTransfer?.types.includes('application/x-t3-files')?'internal':event.dataTransfer?.types.includes('Files')?'external':null; document.addEventListener('dragstart',event=>{const row=event.target.closest('#files-browser [data-files-row]');if(!row||PHONE.matches||where.place==='trash'||pending.has(row.dataset.path))return;if(!current.selection.has(row.dataset.path))select(row.dataset.path);event.dataTransfer.setData('application/x-t3-files',JSON.stringify([...current.selection]));event.dataTransfer.setData('text/plain',[...current.selection].join('\n'));event.dataTransfer.effectAllowed='copyMove';}); diff --git a/docker/setup/client/kit.js b/docker/setup/client/kit.js index 4935642..e5ce9ca 100644 --- a/docker/setup/client/kit.js +++ b/docker/setup/client/kit.js @@ -316,14 +316,15 @@ const Kit = (() => { // ---------------------------------------------------------------- menus -- const menuItems = (items) => items.map((item, i) => item.sep ? html`
` - : html``); + : html``); const wireMenu = (layer, items) => { - const buttons = () => [...layer.panel.querySelectorAll('.tc-menu-item:not([disabled])')]; + const buttons = () => [...layer.panel.querySelectorAll('.tc-menu-item:not([disabled]):not([aria-disabled="true"])')]; layer.panel.addEventListener('click', (e) => { const button = e.target.closest('[data-menu-index]'); if (button) { const item = items[Number(button.getAttribute('data-menu-index'))]; + if (button.disabled || button.getAttribute('aria-disabled') === 'true' || item?.disabled) return; layer.close('select'); if (item && item.run) item.run(); return; @@ -358,16 +359,17 @@ const Kit = (() => { const titleId = 'menu-title-' + Math.random().toString(36).slice(2, 8); const layer = open({ kind: 'bottom', - panel: { tag: 'aside', class: 'tc-sheet tc-sheet--bottom', role: 'menu', 'aria-labelledby': titleId }, + panel: { tag: 'aside', class: 'tc-sheet tc-sheet--bottom tc-sheet--menu', role: 'menu', 'aria-labelledby': titleId }, returnTo: trigger, render: () => html`
${head && head.tile ? head.tile : ''}

${(head && head.title) || label}

${head && head.sub ? html`${head.sub}` : ''}
-
${status || ''}${menuItems(items)}${footer ? html`
${footer}
` : ''}
+
${status || ''}${menuItems(items)}
+ ${footer ? html`
${footer}
` : ''}
`, backdrop: 'close', }); wireMenu(layer, items); - const first = layer.panel.querySelector('.tc-menu-item'); + const first = layer.panel.querySelector('.tc-menu-item:not([disabled]):not([aria-disabled="true"])'); if (first) first.focus({ preventScroll: true }); return layer; } @@ -397,7 +399,7 @@ const Kit = (() => { layer.panel.style.left = left + 'px'; layer.panel.style.top = topPx + 'px'; wireMenu(layer, items); - const first = layer.panel.querySelector('.tc-menu-item'); + const first = layer.panel.querySelector('.tc-menu-item:not([disabled]):not([aria-disabled="true"])'); if (first) first.focus({ preventScroll: true }); return layer; }; diff --git a/docker/setup/console.css b/docker/setup/console.css index ea291e5..e515d53 100644 --- a/docker/setup/console.css +++ b/docker/setup/console.css @@ -592,6 +592,11 @@ html.tc-locked { overflow: hidden; } .tc-sheet-actions .tc-menu-item { min-height: 40px; font-size: 16px; padding: 4px 8px; } .tc-sheet-actions .tc-menu-sep { margin: 6px 8px; } .tc-sheet-cancel { padding: 8px 16px calc(16px + env(safe-area-inset-bottom, 0px)); } +.tc-sheet--menu { overflow: hidden; } +.tc-sheet--menu > .tc-menu-head, .tc-sheet--menu > .tc-sheet-cancel, .tc-sheet--menu > .tc-menu-foot { flex: none; } +.tc-sheet--menu > .tc-sheet-actions { min-height: 0; overflow-y: auto; overscroll-behavior: contain; } +.tc-sheet--menu > .tc-sheet-actions > * { flex: none; } +.tc-sheet--menu > .tc-menu-foot { margin: 0 20px; padding: 8px 0; overflow-wrap: anywhere; } .tc-sheet .tc-sheet-foot-note { margin-right: auto; font-size: 12px; color: var(--fg-muted); display: inline-flex; gap: 6px; align-items: center; } .tc-layer[data-closing] > * { animation: tc-fade-out 140ms ease-in forwards !important; } @@ -2292,14 +2297,15 @@ button.tc-dv-hunk { width: 100%; border-left: 0; border-right: 0; cursor: pointe .tc-fl-row > .tc-fmenu { opacity: 0; justify-self: end; } .tc-fl-row:hover > .tc-fmenu, .tc-fl-row[aria-selected="true"] > .tc-fmenu, .tc-fl-row[data-hover] > .tc-fmenu { opacity: 1; } -/* Selection replaces the column header with what you can do to it. */ -.tc-fl-selbar { display: grid; grid-template-columns: 32px minmax(0, 1fr) auto; align-items: center; column-gap: 12px; min-height: 44px; padding: 0 8px 0 10px; border-bottom: 1px solid color-mix(in srgb, var(--accent) 30%, var(--border)); +/* Selection summary and actions share a row, wrapping as groups when needed. */ +.tc-fl-selbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; min-height: 44px; padding: 8px 10px; border-bottom: 1px solid color-mix(in srgb, var(--accent) 30%, var(--border)); background: color-mix(in srgb, var(--accent) 6%, var(--surface)); border-radius: calc(var(--radius-xl) - 1px) calc(var(--radius-xl) - 1px) 0 0; } [data-theme="dark"] .tc-fl-selbar { background: color-mix(in srgb, var(--accent) 14%, var(--surface)); } -.tc-fl-selbar > div:nth-child(2) { display: flex; align-items: baseline; gap: 8px; font-size: 13px; } -.tc-fl-selbar > div:nth-child(2) strong { font-weight: 500; } -.tc-fl-selbar > div:nth-child(2) span { color: var(--fg-muted); font-size: 12.5px; } -.tc-fl-selbar > div:last-child { display: flex; align-items: center; gap: 4px; } +.tc-fl-selbar > .tc-checkbox { flex: none; } +.tc-files-selection-summary { display: flex; flex: 1; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; min-width: 0; font-size: 13px; } +.tc-files-selection-summary strong { font-weight: 500; white-space: nowrap; } +.tc-files-selection-summary span { color: var(--fg-muted); font-size: 12.5px; } +.tc-files-selection-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 4px; max-width: 100%; margin-left: auto; } /* Footer of the list: counts, hidden items, scan time. */ .tc-fl-foot { display: flex; align-items: center; gap: 8px; min-height: 40px; padding: 0 12px 0 16px; border-top: 1px solid var(--border); font-size: 12px; color: var(--fg-muted); @@ -2520,8 +2526,6 @@ button.tc-dv-hunk { width: 100%; border-left: 0; border-right: 0; cursor: pointe /* ------------------------------------------------------- menus, etc --- */ .tc-ctx { position: absolute; z-index: 40; width: 260px; } -.tc-menu-item[aria-disabled="true"] { color: var(--fg-muted); cursor: default; } -.tc-menu-item[aria-disabled="true"] > .tc-icon { opacity: 0.5; } .tc-menu-item .tc-menu-lock { margin-left: auto; width: 12px; height: 12px; color: var(--fg-subtle); } .tc-menu-item .tc-kbd { margin-left: auto; height: 18px; min-width: 18px; font-size: 11px; background: transparent; border: 0; box-shadow: none; color: var(--fg-muted); padding: 0 2px; letter-spacing: 0.04em; } .tc-menu-item--danger .tc-kbd { color: color-mix(in srgb, var(--danger-text) 70%, transparent); } @@ -2581,6 +2585,7 @@ button.tc-dv-hunk { width: 100%; border-left: 0; border-right: 0; cursor: pointe .tc-prow-file[aria-selected="true"] { background: color-mix(in srgb, var(--accent) 8%, transparent); } .tc-prow-file .tc-checkbox { width: 22px; height: 22px; margin: 0; border-radius: 6px; } .tc-prow-file .tc-checkbox:checked { background-size: 14px; } +#files-browser .tc-prow-file > .tc-btn { min-width: 44px; min-height: 44px; } .tc-pselbar { flex: none; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 4px; padding: 8px 8px 26px; border-top: 1px solid var(--border); background: color-mix(in srgb, var(--bg) 86%, transparent); -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); } .tc-pselbar button { display: flex; flex-direction: column; align-items: center; gap: 4px; height: 52px; justify-content: center; border: 0; border-radius: 10px; background: none; color: var(--fg); font: inherit; font-size: 12px; } .tc-pselbar button .tc-icon { width: 20px; height: 20px; } @@ -2725,7 +2730,15 @@ button.tc-dv-hunk { width: 100%; border-left: 0; border-right: 0; cursor: pointe .tc-files-crumbs > button { flex: none; } .tc-files-crumbs > button:last-child { overflow: hidden; text-overflow: ellipsis; } .tc-files-phone-body { padding: 0 16px; } -.tc-files-phone-selection { position: fixed; bottom: 0; left: 0; right: 0; z-index: var(--z-sticky); display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px)); border-top: 1px solid var(--border); background: var(--surface-raised); box-shadow: var(--shadow-popup); font-size: 14px; } +.tc-files-phone-selection { position: fixed; bottom: 0; left: 0; right: 0; z-index: var(--z-sticky); display: flex; flex-direction: column; gap: 4px; padding: 8px 8px calc(8px + env(safe-area-inset-bottom, 0px)); border-top: 1px solid var(--border); background: var(--surface-raised); box-shadow: var(--shadow-popup); font-size: 14px; } +.tc-files-phone-selection-head { display: flex; align-items: center; justify-content: space-between; min-height: 44px; } +.tc-files-phone-selection-head strong { font-size: 12px; font-weight: 500; } +#files-browser .tc-files-phone-selection-head .tc-btn { min-height: 44px; } +.tc-files-phone-actions.tc-pselbar { grid-template-columns: repeat(auto-fit,minmax(60px,1fr)); padding: 0; border: 0; background: none; -webkit-backdrop-filter: none; backdrop-filter: none; } +#files-browser .tc-files-phone-actions > .tc-btn { width: 100%; min-width: 0; height: 52px; padding: 0 4px; box-shadow: none; } +#files-browser .tc-files-phone-actions > .tc-btn .tc-icon { flex: none; } +#files-browser .tc-files-phone-actions > .tc-btn:disabled { color: var(--fg-muted); } +@media (max-width:639.98px) { body[data-console-route="files"]:has(.tc-files-phone-selection) .tc-toaster { bottom: calc(132px + env(safe-area-inset-bottom, 0px)); } } .tc-files-detail-sheet { max-height: 90dvh; overflow-y: auto; } .tc-files-detail-sheet .tc-fd-head { padding-top: 24px; } @media (min-width: 640px) and (max-width: 1023.98px) { #files-browser .tc-fsearch { flex-basis: 180px; } #files-browser .tc-fl--projects { --fl-cols: 32px minmax(0, 1fr) 150px 80px 72px 32px; } } @@ -3036,8 +3049,6 @@ body[data-console-route="files"]:has(.tc-layer) .tc-toaster { visibility: hidden .tc-files-archive-preview li { display: flex; align-items: center; gap: 8px; font-size: 12px; } .tc-files-archive-preview li .tc-icon { width: 15px; height: 15px; color: var(--fg-muted); } .tc-files-archive-preview code { overflow-wrap: anywhere; } -#files-browser .tc-fl-selbar { grid-template-columns: 24px minmax(100px,1fr); padding: 8px 10px; row-gap: 8px; } -#files-browser .tc-fl-selbar > div:last-child { grid-column: 1 / -1; flex-wrap: wrap; } .tc-storage-data-row:has(a) { grid-template-columns: minmax(0,1fr) auto auto; gap: 12px; } @media(max-width:639.98px) { :is(.tc-files-zip,.tc-files-extract,.tc-files-clean,.tc-files-delete) .tc-fdlg-head { padding: 14px 16px 4px; } @@ -3051,9 +3062,6 @@ body[data-console-route="files"]:has(.tc-layer) .tc-toaster { visibility: hidden .tc-files-extract .tc-input, .tc-files-delete .tc-input { min-height: 44px; font-size: 16px; } .tc-files-delete .tc-risk { grid-template-columns: 18px minmax(0,1fr); padding: 12px; gap: 8px; } .tc-files-delete .tc-risk > .tc-btn { grid-column: 2; justify-self: start; } - #files-browser .tc-files-phone-selection { flex-wrap: wrap; padding: 8px; gap: 4px; } - #files-browser .tc-files-phone-selection > strong { flex-basis: 100%; font-size: 12px; } - #files-browser .tc-files-phone-selection .tc-btn { padding-inline: 8px; } .tc-storage-data-row:has(a) { grid-template-columns: minmax(0,1fr) auto; } .tc-storage-data-row > a { grid-column: 1 / -1; justify-self: start; } } diff --git a/docker/setup/design/components.css b/docker/setup/design/components.css index 0ddbf6e..831e6a2 100644 --- a/docker/setup/design/components.css +++ b/docker/setup/design/components.css @@ -441,6 +441,7 @@ .tc-menu-item .tc-kbd, .tc-menu-item-shortcut { margin-left: auto; font-size: 12px; color: var(--fg-muted); letter-spacing: 0.1em; } .tc-menu-item--danger { color: var(--danger-text); } .tc-menu-item--danger > .tc-icon { color: currentColor; } +.tc-menu-item:disabled, .tc-menu-item[aria-disabled="true"] { color: var(--fg-muted); opacity: var(--opacity-disabled); cursor: not-allowed; background: transparent; } .tc-menu-sep { height: 1px; margin: 4px 0; background: var(--border); border: 0; } .tc-menu-label { padding: 6px 8px 2px; font-size: 12px; font-weight: 500; color: var(--fg-muted); } diff --git a/docs/files.md b/docs/files.md index 08f6bf1..fc0f70e 100644 --- a/docs/files.md +++ b/docs/files.md @@ -63,7 +63,9 @@ Trash is per filesystem, merged into one view. Workspace items use Restore keeps any conflicting name by adding `(restored)`. Items expire after seven days; disk pressure at 90% removes the oldest until usage is below 85%, while preserving the first ten minutes for Undo. Empty Trash requires a fresh -confirmation. Other mounts have no implicit Trash. +confirmation. Other mounts have no implicit Trash. Select items for **Restore** +or **Remove selected…**; on a phone, use **Select items** in View. Trash selection +offers recovery and removal instead of file-editing or transfer actions. Upload files, a folder, clipboard files or a desktop drop. At most three files upload together in ordered 8 MiB chunks. Received data stays under a private @@ -81,8 +83,11 @@ except on the line being edited. A stale save offers Discard mine, Save as a copy, Overwrite theirs and Compare. Saves use a synced temporary file, preserve the mode and publish by rename. The console never executes the edited file. -Managed folders begin locked. **Edit anyway** unlocks only the named managed -root until you leave it; **Lock again** ends that permission immediately. +Managed folders begin locked. Editing actions are muted while previews and +downloads stay available; the menu explains the restriction. **Edit anyway** +unlocks only the named managed root until you leave it; **Lock again** ends that +permission immediately. Phone selections and keyboard shortcuts respect the +same lock. Sign-ins and keys have separate consequences. Process checks fail closed when activity cannot be established. For a supervised T3 process, **Stop T3 Code first** pauses it for the change and verifies that it restarts. Other processes diff --git a/docs/media/files-polish/managed-actions-light.png b/docs/media/files-polish/managed-actions-light.png new file mode 100644 index 0000000..6a82e64 Binary files /dev/null and b/docs/media/files-polish/managed-actions-light.png differ diff --git a/docs/media/files-polish/phone-managed-dark.png b/docs/media/files-polish/phone-managed-dark.png new file mode 100644 index 0000000..8a464bd Binary files /dev/null and b/docs/media/files-polish/phone-managed-dark.png differ diff --git a/docs/media/files-polish/phone-trash-light.png b/docs/media/files-polish/phone-trash-light.png new file mode 100644 index 0000000..06beed1 Binary files /dev/null and b/docs/media/files-polish/phone-trash-light.png differ diff --git a/docs/media/files-polish/trash-selection-dark.png b/docs/media/files-polish/trash-selection-dark.png new file mode 100644 index 0000000..868acc2 Binary files /dev/null and b/docs/media/files-polish/trash-selection-dark.png differ diff --git a/scripts/files-change-ui-audit.js b/scripts/files-change-ui-audit.js index a9664b5..625775c 100644 --- a/scripts/files-change-ui-audit.js +++ b/scripts/files-change-ui-audit.js @@ -8,9 +8,10 @@ const base=process.argv[2]||'http://127.0.0.1:23832/__setup/',key=process.argv[3 const variants=[['desktop',1440,1000],['compact',1280,800],['small-desktop',1100,1000],['tablet',834,1112],['phone',390,844],['narrow',320,740],['embedded',1198,878],['embedded-phone',390,844]],subset=process.env.T3_FILES_UI_VARIANTS?.split(','); (async()=>{const browser=await chromium.launch({executablePath:process.env.CHROME_PATH||'/usr/bin/chromium',args:['--no-sandbox']});let checks=0; try{for(const theme of (process.env.T3_FILES_UI_THEMES?.split(',')||['light','dark']))for(const [variant,width,height]of variants){if(subset&&!subset.includes(variant))continue; - const context=await browser.newContext({viewport:{width,height},colorScheme:theme,reducedMotion:'reduce'}),page=await context.newPage(),fixture=createFilesFixtures(),errors=[];let hold=null,lostFinish=false,extraJobs=[]; + const context=await browser.newContext({viewport:{width,height},colorScheme:theme,reducedMotion:'reduce'}),page=await context.newPage(),fixture=createFilesFixtures(),errors=[],mutations=[];let hold=null,lostFinish=false,extraJobs=[]; page.setDefaultTimeout(10000);page.on('pageerror',error=>errors.push(error.stack));await routeConsole(page,{status:()=>statusFor('skills'),ports:()=>portsFor('ready')}); await page.route('**/*',async route=>{const req=route.request(),url=new URL(req.url());if(!url.pathname.includes('/files/'))return route.fallback();const input=req.method()==='POST'?(url.pathname.endsWith('/chunk')?req.postDataBuffer():req.postDataJSON()):{}; + if(req.method()==='POST')mutations.push(url.pathname); if(hold&&url.pathname.endsWith(hold.route))await hold.promise; const result=await fixture.respond(req.method(),url,input);if(url.pathname.endsWith('/files/jobs'))result.body.jobs.push(...extraJobs);if(lostFinish&&url.pathname.endsWith('/upload/finish')){lostFinish=false;return route.abort('failed');}if(result.raw)return route.fulfill({body:result.raw,headers:result.headers}); if(result.body.etag&&result.body.etag===req.headers()['if-none-match'])return route.fulfill({status:304,headers:{etag:result.body.etag}}); @@ -30,6 +31,30 @@ const variants=[['desktop',1440,1000],['compact',1280,800],['small-desktop',1100 return {immediate,restored:getComputedStyle(host).visibility===before}; }); assert.deepEqual(ownership,{immediate:true,restored:true}); + // The shared menu must communicate and enforce disabled states, including + // synthetic child clicks and a disabled first item on desktop and touch. + await target.evaluate(()=>{ + window.__disabledMenuRuns=0; + Kit.menu(document.querySelector('button'),{label:'Disabled menu probe',items:[ + {label:'Unavailable action',icon:'pencil',disabled:true,run:()=>window.__disabledMenuRuns++}, + {label:'Available action',icon:'eye',run:()=>{}}, + {label:'Unavailable removal',icon:'trash-2',disabled:true,danger:true,run:()=>window.__disabledMenuRuns++}, + {label:'Another action',icon:'copy',run:()=>{}}, + ]}); + }); + assert.equal(await target.evaluate(()=>document.activeElement.textContent.trim()),'Available action','menu initially focuses its first enabled action'); + for(const label of ['Unavailable action','Unavailable removal']){ + const item=target.getByRole('menuitem',{name:label,exact:true});assert.equal(await item.isDisabled(),true);assert.equal(await item.getAttribute('aria-disabled'),'true'); + const style=await item.evaluate(el=>({opacity:Number(getComputedStyle(el).opacity),color:getComputedStyle(el).color})); + assert(style.opacity<1&&style.opacity>=.5,'disabled menu actions remain readable but visibly muted'); + assert.notEqual(style.color,await target.getByRole('menuitem',{name:'Available action',exact:true}).evaluate(el=>getComputedStyle(el).color)); + await item.locator('.tc-menu-item-text').dispatchEvent('click'); + assert.equal(await target.evaluate(()=>window.__disabledMenuRuns),0,'disabled child clicks never run an action'); + assert.equal(await target.getByRole('menuitem',{name:'Available action',exact:true}).count(),1,'a disabled click does not dismiss the menu'); + } + await page.keyboard.press('ArrowDown');assert.equal(await target.evaluate(()=>document.activeElement.textContent.trim()),'Another action','keyboard skips disabled items'); + await page.keyboard.press('Escape'); + try{await visit('files/projects/acme-web/apps/web');await target.locator('[data-path$="next.config.mjs"][data-files-row]').waitFor();await fits(); // Only a real file drag exposes the upload target, and leaving clears it. const drag=await target.evaluateHandle(()=>{const value=new DataTransfer();value.items.add(new File(['fixture'],'drag-only.txt'));return value;}); @@ -59,9 +84,73 @@ const variants=[['desktop',1440,1000],['compact',1280,800],['small-desktop',1100 const row=target.locator('[data-files-row][data-path$="New assets"]');await row.click({button:'right'});await target.getByRole('menuitem',{name:'Move to…',exact:true}).click();await target.locator('.tc-files-move').waitFor();await fits();await shot('move'); await target.locator('[data-move-row="/workspace"]').click();await target.locator('[data-move-submit]:not([disabled])').waitFor();await target.locator('[data-move-submit]').click();await target.locator('.tc-files-move').waitFor({state:'detached'}); await visit('files');const moved=target.locator('[data-files-row][data-path="/workspace/New assets"]');await moved.waitFor();await moved.click({button:'right'});await target.getByRole('menuitem',{name:'Move to Trash',exact:true}).click();await moved.waitFor({state:'detached'}); - await visit('files/trash');await target.locator('[data-files-row]').first().waitFor();assert.equal(await target.locator('.tc-files-drop').count(),0,'Trash never shows a drop target without an active drag');await fits();await shot('trash');await target.locator('[data-cmd="files.restore"]').first().click();await target.getByText('Trash is empty',{exact:true}).waitFor();assert.equal(await target.locator('.tc-files-drop').count(),0,'empty Trash has no drop target'); - // Guarded roots never expose active write controls until explicitly unlocked. - await visit('files/home/.t3');await target.getByRole('button',{name:'Edit anyway…',exact:true}).click();await target.getByRole('alertdialog').waitFor();await target.getByRole('button',{name:'Edit anyway',exact:true}).click();await target.getByRole('button',{name:'Lock again',exact:true}).waitFor();await shot('managed-unlocked');await target.getByRole('button',{name:'Lock again',exact:true}).click(); + await visit('files/trash');await target.locator('[data-files-row]').first().waitFor();assert.equal(await target.locator('.tc-files-drop').count(),0,'Trash never shows a drop target without an active drag');await fits();await shot('trash'); + if(width<640){await target.getByRole('button',{name:'View options',exact:true}).click();await target.getByRole('menuitem',{name:'Select items',exact:true}).click();} + await target.locator('[data-files-check]').first().check(); + const trashSelection=target.getByRole('region',{name:'Trash selection',exact:true});await trashSelection.waitFor();await settle(); + const selectionGeometry=await trashSelection.evaluate(el=>{ + const r=el.getBoundingClientRect(); + return {height:r.height,overflow:[...el.querySelectorAll('button,strong')].filter(item=>{ + const box=item.getBoundingClientRect(),range=document.createRange();range.selectNodeContents(item);const text=range.getBoundingClientRect(); + return box.leftr.right+.5||text.leftbox.right+1; + }).map(item=>item.textContent)}; + }); + assert.deepEqual(selectionGeometry.overflow,[],'Trash selection labels and actions fit their own controls'); + assert(selectionGeometry.height<=(width<640?128:64),'Trash selection stays compact: '+JSON.stringify(selectionGeometry)); + if(width>=640)assert.equal(await target.locator('[data-files-select-all]:checked').count(),2,'selection and column header agree'); + if(width<640){ + assert.deepEqual(await trashSelection.locator('button').evaluateAll(items=>items.filter(el=>{const r=el.getBoundingClientRect();return r.height<44||r.width<44;}).map(el=>el.ariaLabel||el.textContent)),[],'phone selection actions keep touch targets at least44px'); + assert.equal(await target.evaluate(()=>{const bar=document.querySelector('.tc-files-phone-selection').getBoundingClientRect(),notices=document.querySelector('.tc-toaster').getBoundingClientRect();return notices.bottom<=bar.top-8;}),true,'Undo notifications stay above the selection bar'); + } + + if(width<640){assert.equal(await target.locator('[data-cmd="files.restore"][data-path]').first().evaluate(el=>{const r=el.getBoundingClientRect();return r.width>=44&&r.height>=44;}),true,'per-item Restore keeps a touch target');assert.equal(await trashSelection.getByRole('button',{name:'Move',exact:true}).count(),0);assert.equal(await trashSelection.getByRole('button',{name:'Download',exact:true}).count(),0);} + await shot('trash-selected'); + await trashSelection.getByRole('button',{name:'Remove selected…',exact:true}).click();await target.getByRole('alertdialog').waitFor(); + await target.getByRole('button',{name:'Cancel',exact:true}).click();await trashSelection.waitFor(); + await trashSelection.getByRole('button',{name:'Restore',exact:true}).click();await target.getByText('Trash is empty',{exact:true}).waitFor();assert.equal(await target.locator('.tc-files-drop').count(),0,'empty Trash has no drop target'); + if(width<640)await target.getByRole('button',{name:'Done',exact:true}).click(); + // Managed menus explain their lock and restore the same actions after consent. + await fixture.respond('POST',new URL('http://fixture/files/write'),{path:'/home/t3/.t3/example.md',text:'# Managed example\nFixture text.',mode:'create',unlock:'/home/t3/.t3'}); + await visit('files/home/.t3');const managedRow=target.locator('[data-files-row][data-path="/home/t3/.t3/example.md"]');await managedRow.waitFor();await managedRow.click({button:'right'}); + for(const label of ['Edit','Compress','Rename','Move to…','Duplicate','Move to Trash','Delete permanently…']){ + const item=target.getByRole('menuitem',{name:label,exact:true});assert.equal(await item.isDisabled(),true,label+' is locked');assert.equal(await item.getAttribute('aria-disabled'),'true'); + } + for(const label of ['Preview','Details','Download','Copy path','Edit anyway…'])assert.equal(await target.getByRole('menuitem',{name:label,exact:true}).isEnabled(),true,label+' remains available'); + await page.keyboard.press('Escape');await managedRow.focus();const writesBefore=mutations.length; + await target.evaluate(()=>{window.__lockNotices=[];window.__originalToast=Kit.toast;Kit.toast=(...args)=>{const notice=window.__originalToast(...args);window.__lockNotices.push(notice);return notice;};}); + for(const shortcut of ['F2','Control+Shift+M','Control+d','Shift+Delete','Control+u'])await page.keyboard.press(shortcut); + assert.equal(mutations.length,writesBefore,'locked shortcuts never submit a mutation'); + assert.equal(await target.locator('.tc-files-editor,#files-rename-name,.tc-files-move,.tc-files-delete').count(),0,'locked shortcuts do not open unusable editing flows'); + assert.equal(await target.evaluate(()=>window.__lockNotices.length),5,'each locked shortcut explains how to unlock'); + await target.evaluate(()=>{for(const notice of window.__lockNotices)notice.close();Kit.toast=window.__originalToast;delete window.__originalToast;delete window.__lockNotices;}); + await managedRow.click({button:'right'}); + + assert.match(await target.locator('.tc-menu-foot').innerText(),/Choose Edit anyway first/); + if(width<640){ + const menuGeometry=await target.locator('.tc-sheet--menu').evaluate(el=>{ + const panel=el.getBoundingClientRect(),cancel=el.querySelector('[data-menu-cancel]').getBoundingClientRect(),head=el.querySelector('.tc-menu-head').getBoundingClientRect(),foot=el.querySelector('.tc-menu-foot').getBoundingClientRect(),list=el.querySelector('.tc-sheet-actions'); + return {panelFits:panel.left>=0&&panel.right<=innerWidth&&panel.bottom<=innerHeight+1,cancelVisible:cancel.top>=panel.top&&cancel.bottom<=panel.bottom,contextVisible:foot.top>=panel.top&&foot.bottom<=cancel.top,headerVisible:head.top>=panel.top&&head.bottom<=foot.top,scrollable:list.scrollHeight>=list.clientHeight}; + }); + assert.deepEqual(menuGeometry,{panelFits:true,cancelVisible:true,contextVisible:true,headerVisible:true,scrollable:true},'long phone menus keep their heading, reason and Cancel visible'); + } + await shot('managed-locked-menu'); + if(width<640){ + await target.getByRole('menuitem',{name:'Select',exact:true}).click();const bar=target.getByRole('region',{name:'Selection actions',exact:true}); + assert.equal(await bar.getByRole('button',{name:'Move',exact:true}).isDisabled(),true);assert.equal(await bar.getByRole('button',{name:'Trash',exact:true}).isDisabled(),true);assert.equal(await bar.getByRole('button',{name:'Download',exact:true}).isEnabled(),true); + await shot('managed-locked-selection');await bar.getByRole('button',{name:'More selection actions',exact:true}).click(); + assert.equal(await target.getByRole('menuitem',{name:'Compress',exact:true}).isDisabled(),true);assert.equal(await target.getByRole('menuitem',{name:'Delete permanently…',exact:true}).isDisabled(),true); + } + await target.getByRole('menuitem',{name:'Edit anyway…',exact:true}).click();await target.getByRole('alertdialog').waitFor();await target.getByRole('button',{name:'Edit anyway',exact:true}).click();await target.getByRole('button',{name:'Lock again',exact:true}).waitFor(); + if(width<640)await target.getByRole('button',{name:'Done',exact:true}).click(); + await managedRow.click({button:'right'});assert.equal(await target.getByRole('menuitem',{name:'Edit',exact:true}).isEnabled(),true);assert.equal(await target.getByRole('menuitem',{name:'Move to Trash',exact:true}).isEnabled(),true);await target.getByRole('menuitem',{name:'Edit',exact:true}).click();await target.locator('[data-files-editor-text]').waitFor();assert.match(await target.locator('[data-files-editor-text]').inputValue(),/Managed example/);await target.locator('[data-editor-close]').click(); + await shot('managed-unlocked');await target.getByRole('button',{name:'Lock again',exact:true}).click(); + + // Read-only locations use the same disabled appearance without offering unlock. + await fixture.respond('POST',new URL('http://fixture/files/mkdir'),{parent:'/',name:'usr'}); + await fixture.respond('POST',new URL('http://fixture/files/write'),{path:'/usr/example.txt',text:'Read-only fixture.',mode:'create'}); + await visit('files/path/%2Fusr');const readonlyRow=target.locator('[data-files-row][data-path="/usr/example.txt"]');await readonlyRow.waitFor();await readonlyRow.click({button:'right'}); + for(const label of ['Edit','Compress','Rename','Move to…','Duplicate','Move to Trash','Delete permanently…'])assert.equal(await target.getByRole('menuitem',{name:label,exact:true}).isDisabled(),true,label+' is unavailable in a read-only folder'); + assert.equal(await target.getByRole('menuitem',{name:'Edit anyway…',exact:true}).count(),0);assert.equal(await target.locator('.tc-menu-foot').innerText(),'Read-only for t3');await page.keyboard.press('Escape'); // Upload conflict, pause and resume, and atomic final publication. await visit('files/projects/acme-web/apps/web');const picker=page.waitForEvent('filechooser');await target.locator('[data-cmd="files.upload"]').first().click();await (await picker).setFiles({name:'README.md',mimeType:'text/plain',buffer:Buffer.from('Uploaded content')}); if(width<640){await target.locator('.tc-files-transfer-pill').waitFor();await target.locator('.tc-files-transfer-pill').click();}await target.locator('[data-transfer-action="resolve"]').waitFor();await target.locator('[data-transfer-action="resolve"]').click();await target.locator('.tc-files-conflicts').waitFor();await fits();assert.deepEqual(await target.locator('.tc-files-conflicts .tc-conf small').evaluateAll(rows=>rows.filter(el=>el.clientHeight>2*parseFloat(getComputedStyle(el).lineHeight)+1).map(el=>el.textContent)),[],'short conflict summaries use the available width rather than a narrow label column');await shot('upload-conflict');await target.locator('[data-conflict-continue]').click();await target.getByText('Show in web',{exact:true}).waitFor();await target.locator('[data-files-row][data-path$="README (2).md"]').waitFor();await shot('transfer-done'); diff --git a/scripts/lib/files-fixtures.cjs b/scripts/lib/files-fixtures.cjs index a7c4b46..5854cfd 100644 --- a/scripts/lib/files-fixtures.cjs +++ b/scripts/lib/files-fixtures.cjs @@ -75,7 +75,7 @@ function createFilesFixtures({ now = Date.now(), scenario = 'ready', workspace=' if(route==='/files/suggest'){ const q=(url.searchParams.get('q')||'').replace(/^~/,home);return {body:{ok:true,base:path.dirname(q),matches:[...folders.keys()].filter(file=>file.startsWith(q)).slice(0,20).map(file=>({path:file,display:file.replace(home,'~')+'/',bytes:files.get(file)?.bytes||0,tags:[]}))}}; } - if(route==='/files/trash')return {body:{ok:true,items:[...trash.values()].map(({entries,...row})=>row),revision:String(revision),bytes:[...trash.values()].reduce((sum,row)=>sum+row.bytes,0),retentionDays:7,locations:[work+'/.t3-trash',home+'/.t3/trash']}}; + if(route==='/files/trash')return {body:{ok:true,items:[...trash.values()].map(({entries,...row})=>({...row,fromDisplay:path.dirname(row.from).replace(home,'~')})),revision:String(revision),bytes:[...trash.values()].reduce((sum,row)=>sum+row.bytes,0),retentionDays:7,locations:[work+'/.t3-trash',home+'/.t3/trash']}}; if(route==='/files/measure'){measureCount++;return {body:{ok:true,measuring:true,job:'j_fixture_measure'}};} if(route==='/files/zip')return {raw:Buffer.from('504b0506000000000000000000000000000000000000','hex'),headers:{'content-type':'application/zip','content-disposition':'attachment; filename="files.zip"'}}; if(route==='/files/jobs'){for(const row of serverJobs.values())if(row.state==='running'&&Date.now()-row.at>350){row.result=row.finish();delete row.finish;row.state='done';row.done=1;row.finishedAt=new Date().toISOString();}return {body:{ok:true,jobs:[...[...serverJobs.values()].map(({finish,at,...row})=>row),...(measureCount?[{id:'j_fixture_measure',kind:'measure',label:path.basename(value),path:value,state:'done',done:48201,unit:'files',finishedAt:new Date(now).toISOString()}]:[]),...[...uploads.values()].map(row=>({id:row.id,kind:'upload',label:row.path,relative:row.path,dest:row.dest,path:path.join(row.dest,row.path),state:row.result?'done':'waiting',received:row.received,total:row.size,unit:'bytes',result:row.result,finishedAt:row.result?new Date().toISOString():null}))]}};}