1740 lines
51 KiB
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;
|
|
}
|