DVX_GUI/src/apps/kpunch/fileMgr/fileMgr.c

1740 lines
51 KiB
C

// The MIT License (MIT)
//
// Copyright (C) 2026 Scott Duensing
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to
// deal in the Software without restriction, including without limitation the
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
// IN THE SOFTWARE.
// fileMgr.c -- File Manager
//
// A thin user interface over the file operations library (fileops.lib):
// drive bar, path bar, folder tree, sortable file list and status bar,
// with open/run, copy, move, rename, delete, new folder, properties,
// selection and filter commands.
//
// One window per instance (multiInstance: File > New Window starts another
// instance). Cut/Copy/Paste of files between File Manager windows goes
// through the DVX clipboard as text: a header line (FM_CLIP_COPY or
// FM_CLIP_CUT) followed by one full path per line. DVX has no
// drag-and-drop of files between programs, so this is the way to move
// files between windows; pasting the same text into a text editor gives
// the list of paths.
//
// Copy, move and delete run as cooperative tasks (tsCreate), each with its
// own progress window and Cancel button, so the File Manager (and the rest
// of DVX) stays usable. The task advances the job in time slices and
// yields in between; questions ("file exists", "read-only", errors) are
// asked from the task with a small modal dialog.
//
// The tree shows the path from the drive root to the current folder, the
// folders beside each step, and the folders inside the current one.
// Choosing a folder in the tree lists it and adds its sub-folders under it
// in place; navigating any other way rebuilds the tree.
#include "dvxApp.h"
#include "dvxDlg.h"
#include "dvxWgt.h"
#include "dvxWgtP.h"
#include "dvxWm.h"
#include "dvxPlat.h"
#include "shellApp.h"
#include "shellAssoc.h"
#include "taskSwch.h"
#include "stb_ds_wrap.h"
#include "box/box.h"
#include "button/button.h"
#include "checkbox/checkbox.h"
#include "label/label.h"
#include "listView/listView.h"
#include "progressBar/progress.h"
#include "splitter/splitter.h"
#include "statusBar/statBar.h"
#include "textInput/textInpt.h"
#include "toolbar/toolbar.h"
#include "treeView/treeView.h"
#include "fileops/fileOps.h"
#include <inttypes.h>
#include <stdint.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
// ============================================================
// Constants
// ============================================================
#define FM_TITLE "File Manager"
#define FM_WIN_W 600
#define FM_WIN_H 400
#define FM_WIN_X 40
#define FM_WIN_Y 30
#define FM_CASCADE 20 // offset between successive File Manager windows
#define FM_TREE_W 170 // initial width of the folder tree pane
#define FM_TITLE_MAX (DVX_MAX_PATH + 32)
#define FM_STATUS_MAX 128
#define FM_PROMPT_MAX (DVX_MAX_PATH + 96)
#define FM_NAME_MAX DVX_MAX_PATH
#define FM_DEFAULT_FOLDER "NEWFOLDR"
// Widget names (for scripts and tests)
#define FM_NAME_PATH "fmPath"
#define FM_NAME_TREE "fmTree"
#define FM_NAME_LIST "fmList"
#define FM_NAME_STATUS "fmStatus"
// File list columns
#define FM_COL_NAME 0
#define FM_COL_SIZE 1
#define FM_COL_TYPE 2
#define FM_COL_DATE 3
#define FM_COL_COUNT 4
#define FM_COL_NAME_CHARS 14
#define FM_COL_SIZE_CHARS 12
#define FM_COL_TYPE_CHARS 10
#define FM_COL_DATE_CHARS 16
#define FM_PATH_CHARS 40 // visible width of the path bar
// Clipboard format for files
#define FM_CLIP_COPY "DVX-FILES COPY"
#define FM_CLIP_CUT "DVX-FILES CUT"
#define FM_CLIP_EOL '\n'
// Job tasks: stack for the task (modal dialogs run on it) and the time
// slice between yields
#define FM_JOB_STACK (TS_DEFAULT_STACK_SIZE * 2)
#define FM_SLICE_MS 60
#define FM_JOB_WIN_W 360
#define FM_JOB_WIN_H 130
// Question dialog
// Properties dialog
#define FM_PROPS_W 340
// Menu command IDs
#define CMD_FILE_OPEN 100
#define CMD_FILE_NEW_FOLDER 101
#define CMD_FILE_COPY_TO 102
#define CMD_FILE_MOVE_TO 103
#define CMD_FILE_RENAME 104
#define CMD_FILE_DELETE 105
#define CMD_FILE_PROPERTIES 106
#define CMD_FILE_NEW_WINDOW 107
#define CMD_FILE_EXIT 108
#define CMD_EDIT_CUT 200
#define CMD_EDIT_COPY 201
#define CMD_EDIT_PASTE 202
#define CMD_EDIT_SELECT_ALL 203
#define CMD_EDIT_INVERT 204
#define CMD_VIEW_REFRESH 300
#define CMD_VIEW_FILTER 301
#define CMD_VIEW_UP 302
#define CMD_VIEW_SORT_NAME 310
#define CMD_VIEW_SORT_SIZE 311
#define CMD_VIEW_SORT_TYPE 312
#define CMD_VIEW_SORT_DATE 313
#define CMD_VIEW_SORT_DESC 314
#define CMD_HELP_CONTENTS 400
#define CMD_HELP_ABOUT 401
// Deepest folder chain the tree shows (each level is at least two
// characters of the path)
#define FM_TREE_DEPTH_MAX (DVX_MAX_PATH / 2)
#define FM_CUR_DIR "."
// ============================================================
// Types
// ============================================================
// Formatted text for the non-name columns of one row
typedef struct {
char size[FILEOPS_SIZE_TEXT_MAX];
char type[FILEOPS_TYPE_TEXT_MAX];
char date[FILEOPS_DATE_TEXT_MAX];
} FmRowTextT;
// One running copy/move/delete and its progress window
typedef struct {
FileOpsJobT *job;
WindowT *win;
WidgetT *what;
WidgetT *counts;
WidgetT *bar;
const char *verb; // "Copying" / "Moving" / "Deleting"
int32_t taskId;
} FmJobUiT;
// The Properties dialog's attribute check boxes and what OK read from them
typedef struct {
WidgetT *readOnly;
WidgetT *hidden;
WidgetT *system;
WidgetT *archive;
uint8_t chosen;
} FmPropsDlgT;
typedef struct {
WindowT *win;
WidgetT *driveBar;
WidgetT *pathInput;
WidgetT *tree;
WidgetT *list;
WidgetT *statusSel;
WidgetT *statusFree;
AccelTableT *accel;
char curDir[DVX_MAX_PATH];
char filter[FILEOPS_FILTER_MAX];
FileOpsEntryT *entries; // current listing
int32_t entryCount;
const char **cells; // entryCount * FM_COL_COUNT, handed to the list view
FmRowTextT *rowText;
char **treePaths; // stb_ds: path of every tree item (item->userData)
FileOpsSortE sortCol;
bool sortDesc;
bool treeActive; // the tree (not the list) has focus
bool syncingTree; // tree selection is being set by the program
} FmStateT;
// ============================================================
// Prototypes
// ============================================================
static void addDriveButtons(void);
void appShutdown(void);
static void buildMenus(void);
static void buildWindow(void);
static void clipboardPut(bool cut);
static void clipboardSetPaths(const char *header, char **paths);
static void cmdDelete(void);
static void cmdFilter(void);
static void cmdInvert(void);
static void cmdNewFolder(void);
static void cmdOpen(void);
static void cmdPaste(void);
static void cmdProperties(void);
static void cmdRename(void);
static void cmdSelectAll(void);
static void cmdSort(FileOpsSortE col, bool descending);
static void cmdTransferTo(FileOpsOpE op);
static void cmdUp(void);
static int32_t countFileMgrWindows(const AppContextT *ac);
static void freeListing(void);
static void freeSelection(char **paths);
static char **getSelection(void);
static bool jobAddSources(FileOpsJobT *job, char **paths);
static void jobAsk(FmJobUiT *j, const char * const *labels, const FileOpsAnswerE *answers, int32_t count);
static void jobFinish(FmJobUiT *j, FileOpsStepE result);
static void jobOnCancel(WidgetT *w);
static void jobOnClose(WindowT *win);
static void jobShowProgress(FmJobUiT *j);
static void jobStart(FileOpsOpE op, char **paths, const char *destDir);
static void jobTask(void *arg);
static void listOnDblClick(WidgetT *w);
static void listOnFocus(WidgetT *w);
static void listOnHeader(WidgetT *w, int32_t col, ListViewSortE dir);
static void listOnKey(WidgetT *w, int32_t keyCode, int32_t shift);
static void listOnSelChange(WidgetT *w);
static bool loadListing(const char *dir);
static bool navigate(const char *path, bool fromTree);
static void onClose(WindowT *win);
static void onDriveClick(WidgetT *w);
static void onMenu(WindowT *win, int32_t menuId);
static void onPathKey(WidgetT *w, int32_t keyCode, int32_t shift);
static void openEntry(const FileOpsEntryT *e);
static bool propsOnOk(WindowT *dlg, void *userCtx);
static void refresh(void);
static void selectByName(const char *name);
static void selectionAdd(char ***paths, const char *path);
static void setCells(void);
static void showError(const char *title, const char *message);
static void treeAddChildren(WidgetT *parentItem, const char *dir, const char *expandPath, WidgetT **outNext);
static WidgetT *treeAddItem(WidgetT *parent, const char *path, const char *label);
static void treeFreePaths(void);
static void treeOnChange(WidgetT *w);
static void treeOnFocus(WidgetT *w);
static void treeOnKey(WidgetT *w, int32_t keyCode, int32_t shift);
static void treeRebuild(void);
static void updateStatus(void);
static void updateTitle(void);
int32_t appMain(DxeAppContextT *ctx);
// ============================================================
// App descriptor and module state
// ============================================================
SHELL_APP_DESCRIPTOR(
.name = FM_TITLE,
.hasMainLoop = false,
.multiInstance = true,
.stackSize = SHELL_STACK_DEFAULT,
.priority = TS_PRIORITY_NORMAL
);
static DxeAppContextT *sCtx = NULL;
static FmStateT sState;
static FmJobUiT **sJobs = NULL; // stb_ds: running jobs
static const char * const sOverwriteLabels[] = { "&Yes", "&No", "Yes to &All", "&Cancel" };
static const char * const sErrorLabels[] = { "&Retry", "&Skip", "&Cancel" };
static const FileOpsAnswerE sOverwriteAnswers[] = { FileOpsAnswerYesE, FileOpsAnswerNoE, FileOpsAnswerAllE, FileOpsAnswerCancelE };
static const FileOpsAnswerE sErrorAnswers[] = { FileOpsAnswerRetryE, FileOpsAnswerSkipE, FileOpsAnswerCancelE };
// The list view keeps a pointer to the column array.
static const ListViewColT sColumns[FM_COL_COUNT] = {
{ "Name", wgtChars(FM_COL_NAME_CHARS), ListViewAlignLeftE },
{ "Size", wgtChars(FM_COL_SIZE_CHARS), ListViewAlignRightE },
{ "Type", wgtChars(FM_COL_TYPE_CHARS), ListViewAlignLeftE },
{ "Modified", wgtChars(FM_COL_DATE_CHARS), ListViewAlignLeftE }
};
// One button per drive that exists. The DOS probe never touches the
// media, so empty floppy and CD-ROM drives cost nothing here.
static void addDriveButtons(void) {
PlatformDriveT drives[PLATFORM_DRIVE_MAX];
int32_t count = platformDriveList(drives, PLATFORM_DRIVE_MAX);
for (int32_t i = 0; i < count; i++) {
char label[PLATFORM_DRIVE_ROOT_MAX];
// "C:" for a DOS drive, "/" for a single-root file system
snprintf(label, sizeof(label), "%s", drives[i].root);
if (label[1] == ':') {
label[2] = '\0';
}
WidgetT *b = wgtButton(sState.driveBar, label);
b->userData = (void *)(intptr_t)drives[i].root[0];
b->onClick = onDriveClick;
}
}
// Force-kill (Task Manager, shell shutdown): stop every job task and
// release its files; the shell destroys the windows.
void appShutdown(void) {
for (int32_t i = 0; i < (int32_t)arrlen(sJobs); i++) {
tsKill((uint32_t)sJobs[i]->taskId);
fileOpsJobFree(sJobs[i]->job);
free(sJobs[i]);
}
arrfree(sJobs);
freeListing();
treeFreePaths();
if (sState.accel) {
dvxFreeAccelTable(sState.accel);
sState.accel = NULL;
}
}
static void buildMenus(void) {
WindowT *win = sState.win;
MenuBarT *menuBar = wmAddMenuBar(win);
if (!menuBar) {
return;
}
MenuT *file = wmAddMenu(menuBar, "&File");
wmAddMenuItem(file, "&Open\tEnter", CMD_FILE_OPEN);
wmAddMenuItem(file, "New &Folder...", CMD_FILE_NEW_FOLDER);
wmAddMenuSeparator(file);
wmAddMenuItem(file, "&Copy To...\tF8", CMD_FILE_COPY_TO);
wmAddMenuItem(file, "&Move To...\tF7", CMD_FILE_MOVE_TO);
wmAddMenuItem(file, "&Rename...\tF2", CMD_FILE_RENAME);
wmAddMenuItem(file, "&Delete\tDel", CMD_FILE_DELETE);
wmAddMenuItem(file, "P&roperties\tAlt+Enter", CMD_FILE_PROPERTIES);
wmAddMenuSeparator(file);
wmAddMenuItem(file, "New &Window", CMD_FILE_NEW_WINDOW);
wmAddMenuSeparator(file);
wmAddMenuItem(file, "E&xit", CMD_FILE_EXIT);
MenuT *edit = wmAddMenu(menuBar, "&Edit");
wmAddMenuItem(edit, "Cu&t\tCtrl+X", CMD_EDIT_CUT);
wmAddMenuItem(edit, "&Copy\tCtrl+C", CMD_EDIT_COPY);
wmAddMenuItem(edit, "&Paste\tCtrl+V", CMD_EDIT_PASTE);
wmAddMenuSeparator(edit);
wmAddMenuItem(edit, "Select &All\tCtrl+A", CMD_EDIT_SELECT_ALL);
wmAddMenuItem(edit, "&Invert Selection", CMD_EDIT_INVERT);
MenuT *view = wmAddMenu(menuBar, "&View");
wmAddMenuItem(view, "&Refresh\tF5", CMD_VIEW_REFRESH);
wmAddMenuItem(view, "&Filter...", CMD_VIEW_FILTER);
wmAddMenuItem(view, "&Up One Level\tBackspace", CMD_VIEW_UP);
wmAddMenuSeparator(view);
wmAddMenuRadioItem(view, "Sort by &Name", CMD_VIEW_SORT_NAME, true);
wmAddMenuRadioItem(view, "Sort by &Size", CMD_VIEW_SORT_SIZE, false);
wmAddMenuRadioItem(view, "Sort by &Type", CMD_VIEW_SORT_TYPE, false);
wmAddMenuRadioItem(view, "Sort by &Date", CMD_VIEW_SORT_DATE, false);
wmAddMenuSeparator(view);
wmAddMenuCheckItem(view, "D&escending", CMD_VIEW_SORT_DESC, false);
MenuT *help = wmAddMenu(menuBar, "&Help");
wmAddMenuItem(help, "&Contents\tF1", CMD_HELP_CONTENTS);
wmAddMenuSeparator(help);
wmAddMenuItem(help, "&About File Manager...", CMD_HELP_ABOUT);
// Keys that never mean anything to the path bar go in the accelerator
// table; Del, Backspace and Ctrl+A/C/V/X belong to the text box when
// it has focus, so the tree and list handle those themselves.
sState.accel = dvxCreateAccelTable();
dvxAddAccel(sState.accel, KEY_F2, 0, CMD_FILE_RENAME);
dvxAddAccel(sState.accel, KEY_F5, 0, CMD_VIEW_REFRESH);
dvxAddAccel(sState.accel, KEY_F7, 0, CMD_FILE_MOVE_TO);
dvxAddAccel(sState.accel, KEY_F8, 0, CMD_FILE_COPY_TO);
dvxAddAccel(sState.accel, KEY_ENTER, ACCEL_ALT, CMD_FILE_PROPERTIES);
dvxAddAccel(sState.accel, KEY_SCAN_ENTER | KEY_EXT_FLAG, ACCEL_ALT, CMD_FILE_PROPERTIES);
win->accelTable = sState.accel;
}
static void buildWindow(void) {
AppContextT *ac = sCtx->shellCtx;
WidgetT *root = wgtInitWindow(ac, sState.win);
sState.driveBar = wgtToolbar(root);
addDriveButtons();
WidgetT *pathRow = wgtHBox(root);
wgtLabel(pathRow, "&Path:");
sState.pathInput = wgtTextInput(pathRow, DVX_MAX_PATH);
sState.pathInput->weight = WGT_WEIGHT_FILL;
sState.pathInput->prefW = wgtChars(FM_PATH_CHARS);
sState.pathInput->onKeyDown = onPathKey;
wgtSetName(sState.pathInput, FM_NAME_PATH);
WidgetT *split = wgtSplitter(root, true);
split->weight = WGT_WEIGHT_FILL;
sState.tree = wgtTreeView(split);
sState.tree->onChange = treeOnChange;
sState.tree->onFocus = treeOnFocus;
sState.tree->onKeyDown = treeOnKey;
wgtSetName(sState.tree, FM_NAME_TREE);
sState.list = wgtListView(split);
sState.list->onDblClick = listOnDblClick;
sState.list->onChange = listOnSelChange;
sState.list->onFocus = listOnFocus;
sState.list->onKeyDown = listOnKey;
wgtSetName(sState.list, FM_NAME_LIST);
wgtListViewSetColumns(sState.list, sColumns, FM_COL_COUNT);
wgtListViewSetMultiSelect(sState.list, true);
wgtListViewSetHeaderClickCallback(sState.list, listOnHeader);
wgtSplitterSetPos(split, FM_TREE_W);
WidgetT *status = wgtStatusBar(root);
sState.statusSel = wgtLabel(status, "");
sState.statusSel->weight = WGT_WEIGHT_FILL;
sState.statusFree = wgtLabel(status, "");
wgtSetName(sState.statusSel, FM_NAME_STATUS);
}
// Put the selection on the DVX clipboard as a file list.
static void clipboardPut(bool cut) {
char **paths = getSelection();
if (arrlen(paths) > 0) {
clipboardSetPaths(cut ? FM_CLIP_CUT : FM_CLIP_COPY, paths);
}
freeSelection(paths);
}
// Clipboard text: the header line, then one path per line.
static void clipboardSetPaths(const char *header, char **paths) {
char *text = NULL;
for (const char *p = header; *p; p++) {
arrput(text, *p);
}
for (int32_t i = 0; i < (int32_t)arrlen(paths); i++) {
arrput(text, FM_CLIP_EOL);
for (const char *p = paths[i]; *p; p++) {
arrput(text, *p);
}
}
dvxClipboardCopy(text, (int32_t)arrlen(text));
arrfree(text);
}
static void cmdDelete(void) {
char **paths = getSelection();
int32_t count = (int32_t)arrlen(paths);
char msg[FM_PROMPT_MAX];
if (count == 0) {
freeSelection(paths);
return;
}
if (count == 1) {
snprintf(msg, sizeof(msg), "Delete %s?", paths[0]);
} else {
snprintf(msg, sizeof(msg), "Delete these %" PRId32 " items?", count);
}
if (dvxMessageBox(sCtx->shellCtx, "Confirm Delete", msg, MB_YESNO | MB_ICONQUESTION) == ID_YES) {
jobStart(FileOpsDeleteE, paths, NULL);
}
freeSelection(paths);
}
static void cmdFilter(void) {
char buf[FILEOPS_FILTER_MAX];
if (!dvxInputBox(sCtx->shellCtx, "Filter", "Show only files matching:", sState.filter, buf, sizeof(buf))) {
return;
}
snprintf(sState.filter, sizeof(sState.filter), "%s", buf[0] ? buf : PLATFORM_FILTER_ALL);
refresh();
}
static void cmdInvert(void) {
for (int32_t i = 0; i < sState.entryCount; i++) {
wgtListViewSetItemSelected(sState.list, i, !wgtListViewIsItemSelected(sState.list, i));
}
updateStatus();
}
static void cmdNewFolder(void) {
char name[FM_NAME_MAX];
char err[FILEOPS_MSG_MAX];
if (!dvxInputBox(sCtx->shellCtx, "New Folder", "Name of the new folder:", FM_DEFAULT_FOLDER, name, sizeof(name))) {
return;
}
if (!fileOpsMakeDir(sState.curDir, name, err, sizeof(err))) {
showError("New Folder", err);
return;
}
dvxLog("FileMgr: created folder %s", name);
refresh();
selectByName(name);
}
// Enter / double-click / File > Open: a folder is opened in place, a
// program is started, a document opens in its associated program.
static void cmdOpen(void) {
if (sState.treeActive) {
return;
}
int32_t row = wgtListViewGetSelected(sState.list);
if (row >= 0 && row < sState.entryCount) {
openEntry(&sState.entries[row]);
}
}
// Paste the clipboard's file list into the current folder.
static void cmdPaste(void) {
int32_t len = 0;
const char *clip = dvxClipboardGet(&len);
bool cut = false;
size_t headLen;
if (!clip) {
return;
}
if (strncmp(clip, FM_CLIP_CUT, strlen(FM_CLIP_CUT)) == 0 && clip[strlen(FM_CLIP_CUT)] == FM_CLIP_EOL) {
cut = true;
headLen = strlen(FM_CLIP_CUT);
} else if (strncmp(clip, FM_CLIP_COPY, strlen(FM_CLIP_COPY)) == 0 && clip[strlen(FM_CLIP_COPY)] == FM_CLIP_EOL) {
headLen = strlen(FM_CLIP_COPY);
} else {
showError("Paste", "The clipboard holds no files. Use Copy or Cut in a File Manager window first.");
return;
}
char **paths = NULL;
const char *p = clip + headLen + 1;
const char *end = clip + len;
while (p < end) {
const char *eol = memchr(p, FM_CLIP_EOL, (size_t)(end - p));
size_t n = eol ? (size_t)(eol - p) : (size_t)(end - p);
char *path = (char *)malloc(n + 1);
if (path) {
memcpy(path, p, n);
path[n] = '\0';
arrput(paths, path);
}
p += n + 1;
}
jobStart(cut ? FileOpsMoveE : FileOpsCopyE, paths, sState.curDir);
// A cut is used up by its paste.
if (cut) {
dvxClipboardClear();
}
freeSelection(paths);
}
// Size, dates and editable attributes of the selection. Attribute changes
// apply to every selected item.
static void cmdProperties(void) {
AppContextT *ac = sCtx->shellCtx;
char **paths = getSelection();
int32_t count = (int32_t)arrlen(paths);
if (count == 0) {
freeSelection(paths);
return;
}
uint64_t bytes = 0;
int32_t files = 0;
int32_t folders = 0;
bool allRead = true;
for (int32_t i = 0; i < count; i++) {
uint64_t b;
int32_t f;
int32_t d;
struct stat st;
allRead = fileOpsTreeSize(paths[i], &b, &f, &d) && allRead;
bytes += b;
files += f;
folders += d + (stat(paths[i], &st) == 0 && S_ISDIR(st.st_mode) ? 1 : 0);
}
uint8_t attr = 0;
struct stat st;
char line[FM_PROMPT_MAX];
char sizeText[FILEOPS_SIZE_TEXT_MAX];
char dateText[FILEOPS_DATE_TEXT_MAX];
char location[DVX_MAX_PATH];
bool haveStat = stat(paths[0], &st) == 0;
fileOpsGetAttr(paths[0], &attr);
if (!fileOpsPathParent(paths[0], location, sizeof(location))) {
snprintf(location, sizeof(location), "%s", paths[0]);
}
WindowT *win = dvxDialogCreate(ac, "Properties", FM_PROPS_W);
if (!win) {
freeSelection(paths);
return;
}
WidgetT *root = win->widgetRoot;
if (count == 1) {
snprintf(line, sizeof(line), "Name: %s", platformPathBaseName(paths[0]));
} else {
snprintf(line, sizeof(line), "Name: %" PRId32 " items", count);
}
wgtLabel(root, line);
snprintf(line, sizeof(line), "Location: %s", location);
wgtLabel(root, line);
fileOpsFormatSize(bytes, sizeText, sizeof(sizeText));
snprintf(line, sizeof(line), "Size: %s bytes%s", sizeText, allRead ? "" : " (some items could not be read)");
wgtLabel(root, line);
snprintf(line, sizeof(line), "Contains: %" PRId32 " files, %" PRId32 " folders", files, folders);
wgtLabel(root, line);
if (count == 1 && haveStat) {
fileOpsFormatDate(st.st_mtime, dateText, sizeof(dateText));
snprintf(line, sizeof(line), "Modified: %s", dateText);
wgtLabel(root, line);
}
WidgetT *frame = wgtFrame(root, "Attributes");
FmPropsDlgT pd = {0};
pd.readOnly = wgtCheckbox(frame, "&Read-only");
pd.hidden = wgtCheckbox(frame, "&Hidden");
pd.system = wgtCheckbox(frame, "&System");
pd.archive = wgtCheckbox(frame, "&Archive");
wgtCheckboxSetChecked(pd.readOnly, (attr & FILEOPS_ATTR_READONLY) != 0);
wgtCheckboxSetChecked(pd.hidden, (attr & FILEOPS_ATTR_HIDDEN) != 0);
wgtCheckboxSetChecked(pd.system, (attr & FILEOPS_ATTR_SYSTEM) != 0);
wgtCheckboxSetChecked(pd.archive, (attr & FILEOPS_ATTR_ARCHIVE) != 0);
dvxDialogAddOkCancel(win, "&OK", propsOnOk, &pd);
bool apply = dvxDialogRun(ac, win);
uint8_t chosen = pd.chosen;
if (apply) {
bool failed = false;
for (int32_t i = 0; i < count; i++) {
uint8_t old = 0;
if (!fileOpsGetAttr(paths[i], &old) || !fileOpsSetAttr(paths[i], (uint8_t)((old & ~FILEOPS_ATTR_EDITABLE) | chosen))) {
failed = true;
}
}
if (failed) {
showError("Properties", "The attributes of some items could not be changed.");
}
refresh();
}
freeSelection(paths);
}
static void cmdRename(void) {
char **paths = getSelection();
char name[FM_NAME_MAX];
char err[FILEOPS_MSG_MAX];
char dir[DVX_MAX_PATH];
char prompt[FM_PROMPT_MAX];
if (arrlen(paths) != 1) {
if (arrlen(paths) > 1) {
showError("Rename", "Select one file or folder to rename.");
}
freeSelection(paths);
return;
}
const char *oldName = platformPathBaseName(paths[0]);
snprintf(prompt, sizeof(prompt), "New name for %s:", oldName);
if (fileOpsPathParent(paths[0], dir, sizeof(dir)) && dvxInputBox(sCtx->shellCtx, "Rename", prompt, oldName, name, sizeof(name))) {
bool renamedCurrent = fileOpsPathSame(paths[0], sState.curDir);
if (!fileOpsRename(dir, oldName, name, err, sizeof(err))) {
showError("Rename", err);
} else if (renamedCurrent) {
// The tree's current folder itself was renamed.
char newPath[DVX_MAX_PATH];
if (fileOpsPathJoin(newPath, sizeof(newPath), dir, name)) {
snprintf(sState.curDir, sizeof(sState.curDir), "%s", newPath);
}
}
refresh();
}
freeSelection(paths);
}
static void cmdSelectAll(void) {
wgtListViewSelectAll(sState.list);
updateStatus();
}
static void cmdSort(FileOpsSortE col, bool descending) {
static const int32_t sortCmds[] = { CMD_VIEW_SORT_NAME, CMD_VIEW_SORT_SIZE, CMD_VIEW_SORT_TYPE, CMD_VIEW_SORT_DATE };
sState.sortCol = col;
sState.sortDesc = descending;
if (sState.win->menuBar) {
for (int32_t i = 0; i < DVX_ARRAY_LEN(sortCmds); i++) {
wmMenuItemSetChecked(sState.win->menuBar, sortCmds[i], i == (int32_t)col);
}
wmMenuItemSetChecked(sState.win->menuBar, CMD_VIEW_SORT_DESC, descending);
}
fileOpsListSort(sState.entries, sState.entryCount, col, descending);
setCells();
}
// Copy To / Move To: ask for the destination folder.
static void cmdTransferTo(FileOpsOpE op) {
char **paths = getSelection();
int32_t count = (int32_t)arrlen(paths);
const char *verb = op == FileOpsMoveE ? "Move" : "Copy";
char prompt[FM_PROMPT_MAX];
char dest[DVX_MAX_PATH];
char norm[DVX_MAX_PATH];
if (count == 0) {
freeSelection(paths);
return;
}
if (count == 1) {
snprintf(prompt, sizeof(prompt), "%s %s to folder:", verb, platformPathBaseName(paths[0]));
} else {
snprintf(prompt, sizeof(prompt), "%s %" PRId32 " items to folder:", verb, count);
}
if (dvxInputBox(sCtx->shellCtx, verb, prompt, sState.curDir, dest, sizeof(dest))) {
if (fileOpsPathNormalize(dest, norm, sizeof(norm))) {
jobStart(op, paths, norm);
} else {
snprintf(prompt, sizeof(prompt), "%s is not an existing folder, or its drive is not ready.", dest);
showError(verb, prompt);
}
}
freeSelection(paths);
}
static void cmdUp(void) {
char parent[DVX_MAX_PATH];
if (fileOpsPathParent(sState.curDir, parent, sizeof(parent))) {
navigate(parent, false);
}
}
// File Manager windows already open (other instances), used to cascade.
static int32_t countFileMgrWindows(const AppContextT *ac) {
int32_t n = 0;
for (int32_t i = 0; i < ac->stack.count; i++) {
if (strncmp(ac->stack.windows[i]->title, FM_TITLE, strlen(FM_TITLE)) == 0) {
n++;
}
}
return n;
}
// Drop the list view's reference first: it keeps the cell array.
static void freeListing(void) {
if (sState.list) {
wgtListViewSetData(sState.list, NULL, 0);
}
fileOpsListFree(sState.entries, sState.entryCount);
free(sState.cells);
free(sState.rowText);
sState.entries = NULL;
sState.entryCount = 0;
sState.cells = NULL;
sState.rowText = NULL;
}
static void freeSelection(char **paths) {
for (int32_t i = 0; i < (int32_t)arrlen(paths); i++) {
free(paths[i]);
}
arrfree(paths);
}
// Full paths the file commands act on: the tree's folder when the tree has
// focus, else the selected list rows (or the row under the cursor).
static char **getSelection(void) {
char **paths = NULL;
char path[DVX_MAX_PATH];
if (sState.treeActive) {
if (!fileOpsPathIsRoot(sState.curDir)) {
selectionAdd(&paths, sState.curDir);
}
return paths;
}
for (int32_t i = 0; i < sState.entryCount; i++) {
if (wgtListViewIsItemSelected(sState.list, i) && fileOpsPathJoin(path, sizeof(path), sState.curDir, sState.entries[i].name)) {
selectionAdd(&paths, path);
}
}
int32_t row = wgtListViewGetSelected(sState.list);
if (arrlen(paths) == 0 && row >= 0 && row < sState.entryCount && fileOpsPathJoin(path, sizeof(path), sState.curDir, sState.entries[row].name)) {
selectionAdd(&paths, path);
}
return paths;
}
// Add every path to the job; report the ones that cannot be added.
static bool jobAddSources(FileOpsJobT *job, char **paths) {
bool any = false;
for (int32_t i = 0; i < (int32_t)arrlen(paths); i++) {
if (fileOpsJobAddSource(job, paths[i])) {
any = true;
} else {
char msg[FM_PROMPT_MAX];
snprintf(msg, sizeof(msg), "%s cannot be found, or the destination path would be too long. It is skipped.", paths[i]);
showError(FM_TITLE, msg);
}
}
return any;
}
// Put the job's question to the user (last button = Cancel) and hand the
// answer back.
static void jobAsk(FmJobUiT *j, const char * const *labels, const FileOpsAnswerE *answers, int32_t count) {
jobShowProgress(j);
int32_t pick = dvxButtonBox(sCtx->shellCtx, FM_TITLE, fileOpsJobMessage(j->job), MB_ICONQUESTION, labels, count);
fileOpsJobAnswer(j->job, answers[pick]);
}
// Runs on the job's task: close its window, free it, refresh the listing.
static void jobFinish(FmJobUiT *j, FileOpsStepE result) {
AppContextT *ac = sCtx->shellCtx;
dvxLog("FileMgr: %s %s", j->verb, result == FileOpsStepDoneE ? "finished" : "cancelled");
for (int32_t i = 0; i < (int32_t)arrlen(sJobs); i++) {
if (sJobs[i] == j) {
arrdel(sJobs, i);
break;
}
}
if (j->win) {
dvxDestroyWindow(ac, j->win);
}
fileOpsJobFree(j->job);
free(j);
if (sState.win) {
refresh();
}
}
static void jobOnCancel(WidgetT *w) {
FmJobUiT *j = (FmJobUiT *)w->userData;
fileOpsJobCancel(j->job);
}
// The close gadget cancels; the task closes the window when it stops.
static void jobOnClose(WindowT *win) {
FmJobUiT *j = (FmJobUiT *)win->userData;
fileOpsJobCancel(j->job);
}
static void jobShowProgress(FmJobUiT *j) {
FileOpsProgressT p;
char line[FM_PROMPT_MAX];
if (!j->win) {
return;
}
fileOpsJobProgress(j->job, &p);
snprintf(line, sizeof(line), "%s %s", j->verb, p.current);
wgtSetText(j->what, line);
snprintf(line, sizeof(line), "%" PRId32 " of %" PRId32 " files", p.filesDone, p.filesTotal);
wgtSetText(j->counts, line);
wgtProgressBarSetValue(j->bar, p.percent);
}
// Start a copy, move or delete of paths (into destDir) on its own task.
static void jobStart(FileOpsOpE op, char **paths, const char *destDir) {
AppContextT *ac = sCtx->shellCtx;
FileOpsJobT *job = fileOpsJobCreate(op, destDir);
FmJobUiT *j = (FmJobUiT *)calloc(1, sizeof(FmJobUiT));
if (!job || !j) {
fileOpsJobFree(job);
free(j);
showError(FM_TITLE, "Not enough memory to start the operation.");
return;
}
if (!jobAddSources(job, paths)) {
fileOpsJobFree(job);
free(j);
return;
}
j->job = job;
j->verb = op == FileOpsCopyE ? "Copying" : op == FileOpsMoveE ? "Moving" : "Deleting";
j->win = dvxCreateWindowCentered(ac, j->verb, FM_JOB_WIN_W, FM_JOB_WIN_H, false);
if (j->win) {
WidgetT *root = wgtInitWindow(ac, j->win);
j->what = wgtLabel(root, "");
j->bar = wgtProgressBar(root);
j->counts = wgtLabel(root, "");
WidgetT *row = wgtHBox(root);
WidgetT *cancel = wgtButton(row, "&Cancel");
row->align = AlignCenterE;
j->what->weight = WGT_WEIGHT_FILL;
cancel->userData = j;
cancel->onClick = jobOnCancel;
j->win->userData = j;
j->win->onClose = jobOnClose;
}
jobShowProgress(j);
j->taskId = tsCreate(j->verb, jobTask, j, FM_JOB_STACK, TS_PRIORITY_NORMAL);
if (j->taskId < 0) {
if (j->win) {
dvxDestroyWindow(ac, j->win);
}
fileOpsJobFree(job);
free(j);
showError(FM_TITLE, "Not enough memory to start the operation.");
return;
}
arrput(sJobs, j);
}
// The job's task: step through the job in time slices, yielding between
// them, and ask the user when the job needs an answer.
static void jobTask(void *arg) {
FmJobUiT *j = (FmJobUiT *)arg;
PlatformTicksT slice = PLATFORM_MS_TO_TICKS(FM_SLICE_MS);
PlatformTicksT sliceEnd;
FileOpsStepE step;
sCtx->shellCtx->currentAppId = sCtx->appId;
sliceEnd = platformClock() + slice;
for (step = fileOpsJobStep(j->job); step != FileOpsStepDoneE && step != FileOpsStepCancelledE; step = fileOpsJobStep(j->job)) {
if (step == FileOpsStepAskE) {
jobAsk(j, sOverwriteLabels, sOverwriteAnswers, DVX_ARRAY_LEN(sOverwriteLabels));
sliceEnd = platformClock() + slice;
} else if (step == FileOpsStepErrorE) {
jobAsk(j, sErrorLabels, sErrorAnswers, DVX_ARRAY_LEN(sErrorLabels));
sliceEnd = platformClock() + slice;
} else if (platformClock() >= sliceEnd) {
jobShowProgress(j);
tsYield();
sliceEnd = platformClock() + slice;
}
}
jobFinish(j, step);
}
static void listOnDblClick(WidgetT *w) {
(void)w;
cmdOpen();
}
static void listOnFocus(WidgetT *w) {
(void)w;
sState.treeActive = false;
}
// Header click: sort by that column; the same column again reverses. The
// list's own text sort is cleared (sizes and dates need real ordering).
static void listOnHeader(WidgetT *w, int32_t col, ListViewSortE dir) {
static const FileOpsSortE colSort[FM_COL_COUNT] = { FileOpsSortNameE, FileOpsSortSizeE, FileOpsSortTypeE, FileOpsSortDateE };
(void)dir;
wgtListViewSetSort(w, -1, ListViewSortNoneE);
if (col < 0 || col >= FM_COL_COUNT) {
return;
}
FileOpsSortE sort = colSort[col];
cmdSort(sort, sort == sState.sortCol ? !sState.sortDesc : false);
}
static void listOnKey(WidgetT *w, int32_t keyCode, int32_t shift) {
(void)w;
(void)shift;
switch (keyCode) {
case KEY_DELETE:
cmdDelete();
break;
case KEY_BACKSPACE:
cmdUp();
break;
case KEY_CTRL_A:
cmdSelectAll();
break;
case KEY_CTRL_C:
clipboardPut(false);
break;
case KEY_CTRL_X:
clipboardPut(true);
break;
case KEY_CTRL_V:
cmdPaste();
break;
default:
break;
}
}
static void listOnSelChange(WidgetT *w) {
(void)w;
updateStatus();
}
// Read dir into the list (sorted), replacing the current listing. False
// (listing unchanged) when it cannot be read.
static bool loadListing(const char *dir) {
FileOpsEntryT *entries = NULL;
int32_t count = 0;
if (!fileOpsListDir(dir, sState.filter, &entries, &count)) {
return false;
}
freeListing();
sState.entries = entries;
sState.entryCount = count;
sState.cells = (const char **)calloc((size_t)(count > 0 ? count : 1) * FM_COL_COUNT, sizeof(const char *));
sState.rowText = (FmRowTextT *)calloc((size_t)(count > 0 ? count : 1), sizeof(FmRowTextT));
if (!sState.cells || !sState.rowText) {
freeListing();
return false;
}
fileOpsListSort(sState.entries, sState.entryCount, sState.sortCol, sState.sortDesc);
setCells();
return true;
}
// Go to a folder. fromTree: the user chose it in the tree, which then
// only grows its sub-folders; otherwise the tree is rebuilt.
static bool navigate(const char *path, bool fromTree) {
char norm[DVX_MAX_PATH];
char msg[FM_PROMPT_MAX];
if (!fileOpsPathNormalize(path, norm, sizeof(norm)) || !loadListing(norm)) {
snprintf(msg, sizeof(msg), "Cannot open %s.\nThe folder does not exist or the drive is not ready.", path);
showError(FM_TITLE, msg);
return false;
}
snprintf(sState.curDir, sizeof(sState.curDir), "%s", norm);
wgtSetText(sState.pathInput, sState.curDir);
updateTitle();
updateStatus();
if (fromTree) {
WidgetT *item = wgtTreeViewGetSelected(sState.tree);
if (item && !item->firstChild) {
treeAddChildren(item, sState.curDir, NULL, NULL);
wgtTreeItemSetExpanded(item, true);
}
} else {
treeRebuild();
}
return true;
}
// Closing with operations still running asks first; the jobs then stop
// and close their own windows (the program ends with the last one).
static void onClose(WindowT *win) {
AppContextT *ac = sCtx->shellCtx;
if (arrlen(sJobs) > 0) {
if (dvxMessageBox(ac, FM_TITLE, "File operations are still running.\nStop them and close?", MB_YESNO | MB_ICONQUESTION) != ID_YES) {
return;
}
for (int32_t i = 0; i < (int32_t)arrlen(sJobs); i++) {
fileOpsJobCancel(sJobs[i]->job);
}
}
freeListing();
treeFreePaths();
sState.list = NULL;
sState.win = NULL;
dvxDestroyWindow(ac, win);
if (sState.accel) {
dvxFreeAccelTable(sState.accel);
sState.accel = NULL;
}
}
// Drive button: that drive's current folder ("C:" resolves to it).
static void onDriveClick(WidgetT *w) {
char spec[PLATFORM_DRIVE_ROOT_MAX];
char letter = (char)(intptr_t)w->userData;
if (letter == '/') {
snprintf(spec, sizeof(spec), "/");
} else {
snprintf(spec, sizeof(spec), "%c:", letter);
}
navigate(spec, false);
}
static void onMenu(WindowT *win, int32_t menuId) {
(void)win;
switch (menuId) {
case CMD_FILE_OPEN:
cmdOpen();
break;
case CMD_FILE_NEW_FOLDER:
cmdNewFolder();
break;
case CMD_FILE_COPY_TO:
cmdTransferTo(FileOpsCopyE);
break;
case CMD_FILE_MOVE_TO:
cmdTransferTo(FileOpsMoveE);
break;
case CMD_FILE_RENAME:
cmdRename();
break;
case CMD_FILE_DELETE:
cmdDelete();
break;
case CMD_FILE_PROPERTIES:
cmdProperties();
break;
case CMD_FILE_NEW_WINDOW:
shellLoadAppWithArgs(sCtx->shellCtx, sCtx->appPath, sState.curDir);
break;
case CMD_FILE_EXIT:
onClose(sState.win);
break;
case CMD_EDIT_CUT:
clipboardPut(true);
break;
case CMD_EDIT_COPY:
clipboardPut(false);
break;
case CMD_EDIT_PASTE:
cmdPaste();
break;
case CMD_EDIT_SELECT_ALL:
cmdSelectAll();
break;
case CMD_EDIT_INVERT:
cmdInvert();
break;
case CMD_VIEW_REFRESH:
refresh();
break;
case CMD_VIEW_FILTER:
cmdFilter();
break;
case CMD_VIEW_UP:
cmdUp();
break;
case CMD_VIEW_SORT_NAME:
cmdSort(FileOpsSortNameE, sState.sortDesc);
break;
case CMD_VIEW_SORT_SIZE:
cmdSort(FileOpsSortSizeE, sState.sortDesc);
break;
case CMD_VIEW_SORT_TYPE:
cmdSort(FileOpsSortTypeE, sState.sortDesc);
break;
case CMD_VIEW_SORT_DATE:
cmdSort(FileOpsSortDateE, sState.sortDesc);
break;
case CMD_VIEW_SORT_DESC:
cmdSort(sState.sortCol, !sState.sortDesc);
break;
case CMD_HELP_CONTENTS:
shellOpenHelp(sCtx->shellCtx, sCtx->helpFile);
break;
case CMD_HELP_ABOUT:
dvxInfoBox(sCtx->shellCtx, "About File Manager", "File Manager\n\nBrowse, copy, move, rename and delete files and folders.\n\nCopyright 2026 Scott Duensing");
break;
default:
break;
}
}
// Enter in the path bar goes to the typed folder.
static void onPathKey(WidgetT *w, int32_t keyCode, int32_t shift) {
(void)shift;
if (keyCode == KEY_ENTER) {
navigate(wgtGetText(w), false);
}
}
static void openEntry(const FileOpsEntryT *e) {
AppContextT *ac = sCtx->shellCtx;
char path[DVX_MAX_PATH];
char program[DVX_MAX_PATH];
char msg[FM_PROMPT_MAX];
if (!fileOpsPathJoin(path, sizeof(path), sState.curDir, e->name)) {
return;
}
if (e->isDir) {
navigate(path, false);
return;
}
if (dvxHasExt(e->name, ".app")) {
shellLoadApp(ac, path);
return;
}
if (!shellAssocLookup(fileOpsExtension(e->name), program, sizeof(program))) {
snprintf(msg, sizeof(msg), "No program is set to open %s files.\nFile types are set in the Control Panel.", fileOpsExtension(e->name)[0] ? fileOpsExtension(e->name) : "these");
showError(FM_TITLE, msg);
return;
}
if (!shellAssocOpenDocument(ac, path)) {
snprintf(msg, sizeof(msg), "%s could not be opened.", e->name);
showError(FM_TITLE, msg);
}
}
// Properties OK: read the check boxes while they still exist.
static bool propsOnOk(WindowT *dlg, void *userCtx) {
FmPropsDlgT *pd = (FmPropsDlgT *)userCtx;
(void)dlg;
pd->chosen = wgtCheckboxIsChecked(pd->readOnly) ? FILEOPS_ATTR_READONLY : 0;
pd->chosen |= wgtCheckboxIsChecked(pd->hidden) ? FILEOPS_ATTR_HIDDEN : 0;
pd->chosen |= wgtCheckboxIsChecked(pd->system) ? FILEOPS_ATTR_SYSTEM : 0;
pd->chosen |= wgtCheckboxIsChecked(pd->archive) ? FILEOPS_ATTR_ARCHIVE : 0;
return true;
}
// Re-read the current folder, keeping the row under the cursor.
static void refresh(void) {
char keep[FM_NAME_MAX];
int32_t row = wgtListViewGetSelected(sState.list);
keep[0] = '\0';
if (row >= 0 && row < sState.entryCount) {
snprintf(keep, sizeof(keep), "%s", sState.entries[row].name);
}
if (!loadListing(sState.curDir)) {
// The folder is gone (deleted, renamed, disk removed): go up.
char parent[DVX_MAX_PATH];
if (fileOpsPathParent(sState.curDir, parent, sizeof(parent)) && navigate(parent, false)) {
return;
}
freeListing();
}
selectByName(keep);
treeRebuild();
updateTitle();
updateStatus();
}
// Put the list cursor (and selection) on the entry called name, if any.
static void selectByName(const char *name) {
for (int32_t i = 0; i < sState.entryCount; i++) {
if (strcasecmp(sState.entries[i].name, name) == 0) {
wgtListViewSetSelected(sState.list, i);
wgtListViewSetItemSelected(sState.list, i, true);
updateStatus();
return;
}
}
}
// Adds a copy of path to a selection list; a copy that cannot be made is
// left out, so every entry is a string.
static void selectionAdd(char ***paths, const char *path) {
char *copy = strdup(path);
if (copy) {
arrput(*paths, copy);
}
}
// Fill the cell array from the listing and hand it to the list view.
static void setCells(void) {
for (int32_t i = 0; i < sState.entryCount; i++) {
const FileOpsEntryT *e = &sState.entries[i];
FmRowTextT *t = &sState.rowText[i];
const char **cell = &sState.cells[i * FM_COL_COUNT];
t->size[0] = '\0';
if (!e->isDir) {
fileOpsFormatSize(e->size, t->size, sizeof(t->size));
}
fileOpsTypeName(e, t->type, sizeof(t->type));
fileOpsFormatDate(e->mtime, t->date, sizeof(t->date));
cell[FM_COL_NAME] = e->name;
cell[FM_COL_SIZE] = t->size;
cell[FM_COL_TYPE] = t->type;
cell[FM_COL_DATE] = t->date;
}
wgtListViewSetData(sState.list, sState.cells, sState.entryCount);
wgtListViewClearSelection(sState.list);
updateStatus();
}
static void showError(const char *title, const char *message) {
dvxMessageBox(sCtx->shellCtx, title, message, MB_OK | MB_ICONERROR);
}
// Add dir's sub-folders under parentItem. When expandPath is one of them,
// *outNext receives its item.
static void treeAddChildren(WidgetT *parentItem, const char *dir, const char *expandPath, WidgetT **outNext) {
FileOpsEntryT *entries = NULL;
int32_t count = 0;
char path[DVX_MAX_PATH];
if (!fileOpsListDir(dir, NULL, &entries, &count)) {
return;
}
fileOpsListSort(entries, count, FileOpsSortNameE, false);
for (int32_t i = 0; i < count && entries[i].isDir; i++) {
if (!fileOpsPathJoin(path, sizeof(path), dir, entries[i].name)) {
continue;
}
WidgetT *item = treeAddItem(parentItem, path, entries[i].name);
if (item && outNext && expandPath && fileOpsPathSame(path, expandPath)) {
*outNext = item;
}
}
fileOpsListFree(entries, count);
}
// One tree item remembering its folder path.
static WidgetT *treeAddItem(WidgetT *parent, const char *path, const char *label) {
char *copy = strdup(path);
if (!copy) {
return NULL;
}
WidgetT *item = wgtTreeItem(parent, label);
if (!item) {
free(copy);
return NULL;
}
arrput(sState.treePaths, copy);
item->userData = copy;
return item;
}
static void treeFreePaths(void) {
for (int32_t i = 0; i < (int32_t)arrlen(sState.treePaths); i++) {
free(sState.treePaths[i]);
}
arrfree(sState.treePaths);
sState.treePaths = NULL;
}
static void treeOnChange(WidgetT *w) {
if (sState.syncingTree) {
return;
}
WidgetT *item = wgtTreeViewGetSelected(w);
if (item && item->userData && !fileOpsPathSame((const char *)item->userData, sState.curDir)) {
navigate((const char *)item->userData, true);
}
}
static void treeOnFocus(WidgetT *w) {
(void)w;
sState.treeActive = true;
}
static void treeOnKey(WidgetT *w, int32_t keyCode, int32_t shift) {
(void)w;
(void)shift;
if (keyCode == KEY_DELETE) {
cmdDelete();
} else if (keyCode == KEY_BACKSPACE) {
cmdUp();
}
}
// Root, then each folder on the way to the current one with its sibling
// folders, then the current folder's own sub-folders.
static void treeRebuild(void) {
sState.syncingTree = true;
while (sState.tree->firstChild) {
wgtDestroy(sState.tree->firstChild);
}
treeFreePaths();
// Walk up from the current folder to the root, remembering each step
// (one block: the chain is too big for a task stack).
char (*chain)[DVX_MAX_PATH] = malloc(sizeof(*chain) * FM_TREE_DEPTH_MAX);
int32_t depth = 0;
if (chain) {
snprintf(chain[0], sizeof(chain[0]), "%s", sState.curDir);
depth = 1;
while (depth < FM_TREE_DEPTH_MAX && fileOpsPathParent(chain[depth - 1], chain[depth], sizeof(chain[depth]))) {
depth++;
}
}
WidgetT *item = depth > 0 ? treeAddItem(sState.tree, chain[depth - 1], chain[depth - 1]) : NULL;
for (int32_t level = depth - 1; item && level > 0; level--) {
WidgetT *next = NULL;
treeAddChildren(item, chain[level], chain[level - 1], &next);
wgtTreeItemSetExpanded(item, true);
item = next;
}
if (item) {
treeAddChildren(item, sState.curDir, NULL, NULL);
wgtTreeItemSetExpanded(item, true);
wgtTreeViewSetSelected(sState.tree, item);
}
free(chain);
sState.syncingTree = false;
}
static void updateStatus(void) {
char text[FM_STATUS_MAX];
char sizeText[FILEOPS_SIZE_TEXT_MAX];
int32_t selected = 0;
uint64_t bytes = 0;
uint64_t totalBytes;
uint64_t freeBytes;
for (int32_t i = 0; i < sState.entryCount; i++) {
if (wgtListViewIsItemSelected(sState.list, i)) {
selected++;
bytes += sState.entries[i].size;
}
}
fileOpsFormatSize(bytes, sizeText, sizeof(sizeText));
if (selected > 0) {
snprintf(text, sizeof(text), "%" PRId32 " of %" PRId32 " selected (%s bytes)", selected, sState.entryCount, sizeText);
} else {
snprintf(text, sizeof(text), "%" PRId32 " items", sState.entryCount);
}
wgtSetText(sState.statusSel, text);
if (platformDiskSpace(sState.curDir, &totalBytes, &freeBytes)) {
fileOpsFormatSize(freeBytes, sizeText, sizeof(sizeText));
snprintf(text, sizeof(text), "%s bytes free", sizeText);
} else {
text[0] = '\0';
}
wgtSetText(sState.statusFree, text);
}
static void updateTitle(void) {
char title[FM_TITLE_MAX];
bool filtered = strcmp(sState.filter, PLATFORM_FILTER_ALL) != 0;
snprintf(title, sizeof(title), FM_TITLE " - %s%s%s", sState.curDir, filtered ? DVX_PATH_SEP : "", filtered ? sState.filter : "");
dvxSetTitle(sCtx->shellCtx, sState.win, title);
}
// Arguments: an optional starting folder (default: the current one).
int32_t appMain(DxeAppContextT *ctx) {
AppContextT *ac = ctx->shellCtx;
int32_t offset = countFileMgrWindows(ac) * FM_CASCADE;
char start[DVX_MAX_PATH];
sCtx = ctx;
memset(&sState, 0, sizeof(sState));
snprintf(sState.filter, sizeof(sState.filter), "%s", PLATFORM_FILTER_ALL);
sState.sortCol = FileOpsSortNameE;
int32_t winX = FM_WIN_X + offset;
int32_t winY = FM_WIN_Y + offset;
if (winX + FM_WIN_W > ac->display.width || winY + FM_WIN_H > ac->display.height) {
winX = FM_WIN_X;
winY = FM_WIN_Y;
}
sState.win = dvxCreateWindow(ac, FM_TITLE, winX, winY, FM_WIN_W, FM_WIN_H, true);
if (!sState.win) {
return -1;
}
sState.win->onClose = onClose;
sState.win->onMenu = onMenu;
buildMenus();
buildWindow();
if (ctx->args[0]) {
snprintf(start, sizeof(start), "%s", ctx->args);
} else if (!getcwd(start, sizeof(start))) {
snprintf(start, sizeof(start), "%s", FM_CUR_DIR);
}
if (!navigate(start, false)) {
PlatformDriveT drive;
if (platformDriveList(&drive, 1) == 1) {
navigate(drive.root, false);
}
}
wgtSetFocused(sState.list);
sState.treeActive = false;
return 0;
}