diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index d8ba596566..64eb98dc30 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -1,13 +1,2 @@
-blank_issues_enabled: false
-
-contact_links:
- - name: Questions or discussions about Midnight Commander
- url: https://github.com/MidnightCommander/mc/discussions
- about: Please ask for support and answer questions here
-
- - name: List for Midnight Commander users
- url: https://lists.midnight-commander.org/mailman/listinfo/mc
- about: If you prefer mailing lists, post your questions to the users list
- - name: List for Midnight Commander developers
- url: https://lists.midnight-commander.org/mailman/listinfo/mc-devel
- about: If you prefer mailing lists, post development-related messages to the developers list
+blank_issues_enabled: true
+contact_links: []
diff --git a/.github/workflows/issue-label-approve.yml b/.github/workflows/issue-label-approve.yml
index ea5b4388eb..419c31a07f 100644
--- a/.github/workflows/issue-label-approve.yml
+++ b/.github/workflows/issue-label-approve.yml
@@ -24,7 +24,7 @@ jobs:
- run: |
gh issue edit \
${{ inputs.issue_number != '' && inputs.issue_number || github.event.issue.number }} \
- --remove-label "state: in review"
+ --remove-label "state: in review" || true
gh issue comment \
${{ inputs.issue_number != '' && inputs.issue_number || github.event.issue.number }} \
diff --git a/.github/workflows/issue-label-review.yml b/.github/workflows/issue-label-review.yml
index 2308e1d971..cf04f4189e 100644
--- a/.github/workflows/issue-label-review.yml
+++ b/.github/workflows/issue-label-review.yml
@@ -24,7 +24,7 @@ jobs:
- run: |
gh issue edit \
${{ inputs.issue_number != '' && inputs.issue_number || github.event.issue.number }} \
- --remove-label "state: approved"
+ --remove-label "state: approved" || true
gh issue comment \
${{ inputs.issue_number != '' && inputs.issue_number || github.event.issue.number }} \
diff --git a/.github/workflows/pr-label-approve.yml b/.github/workflows/pr-label-approve.yml
index a178eb45fa..b4207c21a6 100644
--- a/.github/workflows/pr-label-approve.yml
+++ b/.github/workflows/pr-label-approve.yml
@@ -31,7 +31,7 @@ jobs:
gh pr edit \
${{ inputs.issue_number != '' && inputs.issue_number || github.event.pull_request.number }} \
- --remove-label "state: in review"
+ --remove-label "state: in review" || true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
diff --git a/.github/workflows/pr-summary.yml b/.github/workflows/pr-summary.yml
new file mode 100644
index 0000000000..70f442d299
--- /dev/null
+++ b/.github/workflows/pr-summary.yml
@@ -0,0 +1,111 @@
+name: PR summary from commits
+
+on:
+ pull_request:
+ types: [opened, reopened, synchronize, edited]
+
+permissions:
+ contents: read
+ pull-requests: write
+
+jobs:
+ summarize:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Update PR body with commit summary
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const owner = context.repo.owner;
+ const repo = context.repo.repo;
+ const pr = context.payload.pull_request;
+
+ // For PRs from forks, GITHUB_TOKEN may be read-only (depends on repo settings).
+ // We'll try; if forbidden, the action will fail.
+ const prNumber = pr.number;
+
+ const markerStart = "";
+ const markerEnd = "";
+
+ // 1) Collect commits
+ const commits = await github.paginate(github.rest.pulls.listCommits, {
+ owner,
+ repo,
+ pull_number: prNumber,
+ per_page: 100
+ });
+
+ // 2) Collect changed files (limited to 300 for readability)
+ const files = await github.paginate(github.rest.pulls.listFiles, {
+ owner,
+ repo,
+ pull_number: prNumber,
+ per_page: 100
+ });
+
+ const commitLines = commits.map(c => {
+ const msg = (c.commit?.message || "").split("\n")[0].trim();
+ const sha = (c.sha || "").slice(0, 7);
+ return `- ${msg} (\`${sha}\`)`;
+ });
+
+ // Group files by status
+ const limit = 300;
+ const trimmedFiles = files.slice(0, limit);
+
+ const byStatus = new Map();
+ for (const f of trimmedFiles) {
+ const st = f.status || "modified";
+ if (!byStatus.has(st)) byStatus.set(st, []);
+ byStatus.get(st).push(f.filename);
+ }
+
+ const statusOrder = ["added", "modified", "removed", "renamed", "changed"];
+ const fileSectionParts = [];
+ for (const st of statusOrder) {
+ if (!byStatus.has(st)) continue;
+ const names = byStatus.get(st);
+ const shown = names.slice(0, 80);
+ const extra = names.length - shown.length;
+ const list = shown.map(n => ` - \`${n}\``).join("\n");
+ fileSectionParts.push(`**${st}** (${names.length})\n${list}${extra > 0 ? `\n - ... (+${extra} more)` : ""}`);
+ }
+
+ const commitCount = commits.length;
+ const fileCount = files.length;
+
+ const summaryBlock =
+ `${markerStart}\n` +
+ `## Auto summary\n\n` +
+ `**Commits:** ${commitCount} \n` +
+ `**Files changed:** ${fileCount}\n\n` +
+ `### Commit messages\n` +
+ `${commitLines.length ? commitLines.join("\n") : "_No commits found_"}\n\n` +
+ `### Changed files\n` +
+ `${fileSectionParts.length ? fileSectionParts.join("\n\n") : "_No files found_"}\n\n` +
+ `_This block is auto-generated. Edit outside this section._\n` +
+ `${markerEnd}\n`;
+
+ const currentBody = pr.body || "";
+
+ const hasMarkers = currentBody.includes(markerStart) && currentBody.includes(markerEnd);
+
+ let newBody;
+ if (hasMarkers) {
+ const startIdx = currentBody.indexOf(markerStart);
+ const endIdx = currentBody.indexOf(markerEnd) + markerEnd.length;
+ newBody = currentBody.slice(0, startIdx).trimEnd() + "\n\n" + summaryBlock + "\n" + currentBody.slice(endIdx).trimStart();
+ } else {
+ // Prepend summary
+ newBody = summaryBlock + "\n" + currentBody;
+ }
+
+ // Avoid unnecessary updates
+ if (newBody !== currentBody) {
+ await github.rest.pulls.update({
+ owner,
+ repo,
+ pull_number: prNumber,
+ body: newBody
+ });
+ }
diff --git a/.github/workflows/process-closed-issues.yml b/.github/workflows/process-closed-issues.yml
index e7f8ae818e..f8bcd5c57c 100644
--- a/.github/workflows/process-closed-issues.yml
+++ b/.github/workflows/process-closed-issues.yml
@@ -22,7 +22,7 @@ jobs:
- run: |
ISSUE=${{ inputs.issue_number != '' && inputs.issue_number || github.event.issue.number }}
- gh issue edit $ISSUE --remove-label "state: in review,state: approved"
+ gh issue edit $ISSUE --remove-label "state: in review,state: approved" || true
if [ "${{ github.event.issue.state_reason }}" = "not_planned" ]; then
gh issue edit $ISSUE --remove-milestone
diff --git a/.github/workflows/process-closed-prs.yml b/.github/workflows/process-closed-prs.yml
index 5a93802bf9..c312613974 100644
--- a/.github/workflows/process-closed-prs.yml
+++ b/.github/workflows/process-closed-prs.yml
@@ -24,7 +24,7 @@ jobs:
steps:
- run: |
gh pr edit ${{ inputs.pr_number != '' && inputs.pr_number || github.event.pull_request.number }} \
- --remove-label "state: in review,state: approved"
+ --remove-label "state: in review,state: approved" || true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
diff --git a/configure.ac b/configure.ac
index 5bdc626a87..6d7bdb4b8f 100644
--- a/configure.ac
+++ b/configure.ac
@@ -470,6 +470,20 @@ mc_BACKGROUND
mc_EXT2FS_ATTR
mc_VFS_CHECKS
+AC_ARG_WITH([vfs-plugins-dir],
+ AS_HELP_STRING([--with-vfs-plugins-dir=DIR],
+ [Directory for dynamic VFS plugins [LIBDIR/mc/vfs-plugins]]),
+ [vfs_plugins_dir="$withval"],
+ [vfs_plugins_dir='${libdir}/mc/vfs-plugins'])
+AC_SUBST([vfs_plugins_dir])
+
+AC_ARG_WITH([panel-plugins-dir],
+ AS_HELP_STRING([--with-panel-plugins-dir=DIR],
+ [Directory for dynamic panel plugins [LIBDIR/mc/panel-plugins]]),
+ [panel_plugins_dir="$withval"],
+ [panel_plugins_dir='${libdir}/mc/panel-plugins'])
+AC_SUBST([panel_plugins_dir])
+
dnl ############################################################################
dnl Directories
dnl ############################################################################
@@ -543,9 +557,8 @@ AM_CONDITIONAL(USE_INTERNAL_EDIT, [test x"$use_internal_edit" = xyes ])
AM_CONDITIONAL(USE_ASPELL, [test x"$enable_aspell" = xyes ])
AM_CONDITIONAL(USE_DIFF, [test -n "$use_diff"])
AM_CONDITIONAL(CONS_SAVER, [test -n "$cons_saver"])
-dnl Clarify do we really need GModule
-AM_CONDITIONAL([HAVE_GMODULE], [test -n "$g_module_supported" && \
- test x"$textmode_x11_support" = x"yes" -o x"$enable_aspell" = x"yes"])
+dnl Enable GModule when available (used for X11, aspell, and dynamic VFS plugins)
+AM_CONDITIONAL([HAVE_GMODULE], [test -n "$g_module_supported"])
AC_ARG_ENABLE([configure-args],
AS_HELP_STRING([--enable-configure-args], [Embed ./configure arguments into binaries]))
@@ -588,6 +601,12 @@ src/viewer/Makefile
src/diffviewer/Makefile
src/filemanager/Makefile
+src/panel-plugins/Makefile
+src/panel-plugins/hello/Makefile
+src/panel-plugins/systemd/Makefile
+src/panel-plugins/sftp/Makefile
+src/panel-plugins/docker/Makefile
+
src/vfs/Makefile
src/vfs/cpio/Makefile
diff --git a/doc/INSTALL b/doc/INSTALL
index 03aafee477..fdaa68bd84 100644
--- a/doc/INSTALL
+++ b/doc/INSTALL
@@ -176,7 +176,11 @@ VFS options:
`--enable-vfs-sftp'
(auto)
- Support for SFTP vfs
+ Support for SFTP vfs. When enabled, SFTP is built as a dynamic
+ plugin (mc-vfs-sftp.so) installed into the VFS plugins directory
+ (see --with-vfs-plugins-dir). Requires libssh2 >= 1.2.8.
+ The main mc binary does not link against libssh2; the plugin
+ carries that dependency itself.
`--enable-vfs-extfs'
(on by default)
@@ -186,6 +190,16 @@ VFS options:
(on by default)
Support for sfs
+`--with-vfs-plugins-dir=DIR'
+ Set the directory where Midnight Commander looks for dynamic VFS
+ plugins at runtime [default=LIBDIR/mc/vfs-plugins]. A dynamic VFS
+ plugin is a shared object (.so) that exports the mc_vfs_plugin_init
+ symbol. When MC starts, it scans this directory and loads every
+ matching plugin, allowing third-party VFS modules (e.g. SMB, WebDAV)
+ to be added without recompiling MC. GModule (part of GLib) is
+ required for this feature; if unavailable, dynamic loading is
+ silently skipped.
+
Screen library:
- - - - - - - -
diff --git a/doc/PLUGINS b/doc/PLUGINS
new file mode 100644
index 0000000000..d8e3a6b548
--- /dev/null
+++ b/doc/PLUGINS
@@ -0,0 +1,121 @@
+Dynamic VFS plugins for GNU Midnight Commander
+===============================================
+
+Since version 4.8.33 Midnight Commander can load VFS modules at runtime
+from shared objects (.so). This lets distributions package optional VFS
+modules (and their external dependencies) separately from the core mc
+binary.
+
+How it works
+------------
+
+At startup, after initialising the built-in (static) VFS modules,
+mc scans the directory specified by --with-vfs-plugins-dir (default:
+${libdir}/mc/vfs-plugins). Every .so file found there is opened with
+GModule (dlopen) and probed for the symbol:
+
+ void mc_vfs_plugin_init (void);
+
+If the symbol is found it is called immediately. The plugin is expected
+to register its VFS class via vfs_init_subclass() / vfs_register_class()
+-- exactly the same way a statically linked module does.
+
+The mc binary is linked with -export-dynamic so that plugin code can
+call back into mc (vfs_init_subclass, tcp_init, query_dialog, etc.)
+without linking those symbols explicitly.
+
+GModule support (part of GLib) is required. If it is unavailable at
+build time, dynamic loading is silently skipped.
+
+
+Current dynamic plugins
+-----------------------
+
+ mc-vfs-sftp.so SFTP filesystem (requires libssh2 >= 1.2.8)
+
+
+Candidates for future conversion
+---------------------------------
+
+The following modules are currently compiled as static libraries and
+linked into libmc-vfs.la. They are listed in order of conversion
+difficulty.
+
+Phase 1 -- no code changes needed (zero external symbol references):
+
+ tar tar archives no external deps
+ cpio cpio archives no external deps
+ extfs extension filesystem no external deps (uses helper scripts)
+ sfs single-file fs no external deps
+
+ These modules are fully self-contained: their init functions are only
+ called from src/vfs/plugins_init.c and no code outside src/vfs/
+ references their symbols. Conversion is mechanical: rename the init
+ function to mc_vfs_plugin_init, switch Makefile.am from noinst to
+ vfsplugin, and remove the static init call.
+
+Phase 2 -- minimal refactoring (one exported global variable):
+
+ shell SHELL/SSH filesystem no external deps
+
+ src/setup.c reads/writes shell_directory_timeout. A getter/setter
+ pair or an mc_config-based approach would decouple it.
+
+Phase 3 -- moderate refactoring (multiple exported globals):
+
+ ftpfs FTP filesystem no external deps
+
+ src/setup.c and src/filemanager/boxes.c directly access ~11 global
+ variables (ftpfs_use_netrc, ftpfs_proxy_host, ftpfs_anonymous_passwd,
+ ftpfs_use_passive_connections, etc.) for the configuration dialog and
+ settings persistence. These would need to be exposed through a
+ callback/getter API or moved to mc_config key lookups.
+
+Not convertible:
+
+ local local filesystem core module, must be first
+
+ Other VFS modules depend on local_close(), local_read(), etc.
+ It must be initialised before any other VFS class.
+
+
+Writing a new plugin
+--------------------
+
+1. Create a directory under src/vfs/ (e.g. src/vfs/myfs/).
+
+2. Implement your VFS class. Use src/vfs/sftpfs/ as a reference.
+ The key function is:
+
+ void
+ mc_vfs_plugin_init (void)
+ {
+ tcp_init (); /* if network-based */
+ vfs_init_subclass (&myfs_subclass, "myfs", flags, "myfs");
+ /* assign callbacks to vfs_myfs_ops->open, ->read, ... */
+ vfs_register_class (vfs_myfs_ops);
+ }
+
+3. In Makefile.am:
+
+ vfsplugindir = $(vfs_plugins_dir)
+ vfsplugin_LTLIBRARIES = mc-vfs-myfs.la
+
+ mc_vfs_myfs_la_SOURCES = ...
+ mc_vfs_myfs_la_LDFLAGS = -module -avoid-version
+ mc_vfs_myfs_la_LIBADD = $(GLIB_LIBS) $(MY_EXTERNAL_LIBS)
+
+ Do NOT add -no-undefined -- the plugin resolves mc symbols at
+ runtime via -export-dynamic on the mc binary.
+
+4. If the plugin has an external library dependency, add a PKG_CHECK
+ in an m4 file but do NOT append to MCLIBS -- the plugin links the
+ library itself.
+
+5. Add SUBDIRS += myfs to src/vfs/Makefile.am (guarded by your
+ AM_CONDITIONAL), but do NOT add to libmc_vfs_la_LIBADD.
+
+6. Install the resulting .so into $(vfs_plugins_dir) -- this happens
+ automatically via vfsplugin_LTLIBRARIES.
+
+7. Test: run mc and verify your prefix is accessible (e.g. cd myfs://).
diff --git a/lib/Makefile.am b/lib/Makefile.am
index f055b87b76..d69c361652 100644
--- a/lib/Makefile.am
+++ b/lib/Makefile.am
@@ -43,6 +43,8 @@ libmc_la_SOURCES = \
global.c global.h \
keybind.c keybind.h \
lock.c lock.h \
+ panel-plugin.c panel-plugin.h \
+ panel-plugin-loader.c \
serialize.c serialize.h \
shell.c shell.h \
stat-size.h \
@@ -57,7 +59,8 @@ libmc_la_SOURCES += charsets.c charsets.h
EXTRA_DIST = \
stdckdint.in.h
-AM_CPPFLAGS = $(GLIB_CFLAGS) -I$(top_srcdir)
+AM_CPPFLAGS = $(GLIB_CFLAGS) -I$(top_srcdir) \
+ -DMC_PANEL_PLUGINS_DIR=\""$(panel_plugins_dir)"\"
libmc_la_LIBADD = \
event/libmcevent.la \
diff --git a/lib/keybind.c b/lib/keybind.c
index 3a4550eda8..a9c1432a0a 100644
--- a/lib/keybind.c
+++ b/lib/keybind.c
@@ -231,6 +231,7 @@ static name_keymap_t command_names[] = {
ADD_KEYMAP_NAME (CdParent),
ADD_KEYMAP_NAME (CdChild),
ADD_KEYMAP_NAME (Panelize),
+ ADD_KEYMAP_NAME (PanelPlugin),
ADD_KEYMAP_NAME (PanelOtherSync),
ADD_KEYMAP_NAME (SortNext),
ADD_KEYMAP_NAME (SortPrev),
@@ -305,6 +306,8 @@ static name_keymap_t command_names[] = {
ADD_KEYMAP_NAME (BookmarkFlush),
ADD_KEYMAP_NAME (BookmarkNext),
ADD_KEYMAP_NAME (BookmarkPrev),
+ ADD_KEYMAP_NAME (FoldToggle),
+ ADD_KEYMAP_NAME (UnfoldAll),
ADD_KEYMAP_NAME (MarkPageUp),
ADD_KEYMAP_NAME (MarkPageDown),
ADD_KEYMAP_NAME (MarkToFileBegin),
diff --git a/lib/keybind.h b/lib/keybind.h
index 28be23559e..607a457cdd 100644
--- a/lib/keybind.h
+++ b/lib/keybind.h
@@ -190,6 +190,7 @@ enum
CK_PanelOtherCd = 200L,
CK_PanelOtherCdLink,
CK_Panelize,
+ CK_PanelPlugin,
CK_CopySingle,
CK_MoveSingle,
CK_DeleteSingle,
@@ -255,6 +256,9 @@ enum
CK_BookmarkFlush,
CK_BookmarkNext,
CK_BookmarkPrev,
+ // code folding
+ CK_FoldToggle,
+ CK_UnfoldAll,
// mark commands
CK_MarkColumn,
CK_MarkWord,
diff --git a/lib/panel-plugin-loader.c b/lib/panel-plugin-loader.c
new file mode 100644
index 0000000000..e3f0d3c066
--- /dev/null
+++ b/lib/panel-plugin-loader.c
@@ -0,0 +1,134 @@
+/*
+ Dynamic panel plugin loader.
+
+ Copyright (C) 2025
+ Free Software Foundation, Inc.
+
+ This file is part of the Midnight Commander.
+
+ The Midnight Commander is free software: you can redistribute it
+ and/or modify it under the terms of the GNU General Public License as
+ published by the Free Software Foundation, either version 3 of the License,
+ or (at your option) any later version.
+
+ The Midnight Commander is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+ */
+
+/** \file panel-plugin-loader.c
+ * \brief Source: dynamic panel plugin loader
+ *
+ * Scans MC_PANEL_PLUGINS_DIR for shared objects exporting
+ * mc_panel_plugin_register(), loads them, and registers the returned
+ * mc_panel_plugin_t descriptor via mc_panel_plugin_add().
+ */
+
+#include
+
+#include
+
+#include "lib/global.h"
+#include "lib/panel-plugin.h"
+
+#ifdef HAVE_GMODULE
+
+#include
+
+/*** global variables ****************************************************************************/
+
+/*** file scope macro definitions ****************************************************************/
+
+/*** file scope type declarations ****************************************************************/
+
+/*** file scope variables ************************************************************************/
+
+static GPtrArray *panel_plugin_modules = NULL;
+
+/*** file scope functions ************************************************************************/
+/* --------------------------------------------------------------------------------------------- */
+
+/* --------------------------------------------------------------------------------------------- */
+/*** public functions ****************************************************************************/
+/* --------------------------------------------------------------------------------------------- */
+
+void
+mc_panel_plugins_load (void)
+{
+ GDir *dir;
+ const gchar *filename;
+ gchar *plugins_dir;
+
+ plugins_dir = g_build_filename (MC_PANEL_PLUGINS_DIR, (char *) NULL);
+ dir = g_dir_open (plugins_dir, 0, NULL);
+ if (dir == NULL)
+ {
+ g_free (plugins_dir);
+ return; // no plugins dir — OK, nothing to load
+ }
+
+ panel_plugin_modules = g_ptr_array_new ();
+
+ while ((filename = g_dir_read_name (dir)) != NULL)
+ {
+ GModule *module;
+ mc_panel_plugin_register_fn register_fn;
+ const mc_panel_plugin_t *plugin;
+ gchar *path;
+
+ if (!g_str_has_suffix (filename, "." G_MODULE_SUFFIX))
+ continue;
+
+ path = g_build_filename (plugins_dir, filename, (char *) NULL);
+ module = g_module_open (path, G_MODULE_BIND_LAZY);
+ if (module == NULL)
+ {
+ fprintf (stderr, "Panel plugin %s: %s\n", filename, g_module_error ());
+ g_free (path);
+ continue;
+ }
+
+ if (!g_module_symbol (module, MC_PANEL_PLUGIN_ENTRY, (gpointer *) ®ister_fn))
+ {
+ fprintf (stderr, "Panel plugin %s: symbol %s not found\n", filename,
+ MC_PANEL_PLUGIN_ENTRY);
+ g_module_close (module);
+ g_free (path);
+ continue;
+ }
+
+ plugin = register_fn ();
+ if (plugin == NULL || !mc_panel_plugin_add (plugin))
+ {
+ fprintf (stderr, "Panel plugin %s: registration failed\n", filename);
+ g_module_close (module);
+ g_free (path);
+ continue;
+ }
+
+ g_module_make_resident (module); // prevent unload — plugin descriptor lives in .so
+ g_ptr_array_add (panel_plugin_modules, module);
+ g_free (path);
+ }
+
+ g_dir_close (dir);
+ g_free (plugins_dir);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+#else /* !HAVE_GMODULE */
+
+void
+mc_panel_plugins_load (void)
+{
+ // GModule not available — dynamic panel plugins disabled
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+#endif /* HAVE_GMODULE */
diff --git a/lib/panel-plugin.c b/lib/panel-plugin.c
new file mode 100644
index 0000000000..645b50736d
--- /dev/null
+++ b/lib/panel-plugin.c
@@ -0,0 +1,148 @@
+/*
+ Panel plugin registry.
+
+ Copyright (C) 2025
+ Free Software Foundation, Inc.
+
+ This file is part of the Midnight Commander.
+
+ The Midnight Commander is free software: you can redistribute it
+ and/or modify it under the terms of the GNU General Public License as
+ published by the Free Software Foundation, either version 3 of the License,
+ or (at your option) any later version.
+
+ The Midnight Commander is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+ */
+
+/** \file panel-plugin.c
+ * \brief Source: panel plugin registry
+ *
+ * Maintains a list of registered mc_panel_plugin_t descriptors.
+ * Plugins are registered via mc_panel_plugin_add() — typically called
+ * from the dynamic loader (panel-plugin-loader.c).
+ */
+
+#include
+
+#include
+#include
+
+#include "lib/global.h"
+#include "lib/panel-plugin.h"
+
+/*** global variables ****************************************************************************/
+
+/*** file scope macro definitions ****************************************************************/
+
+/*** file scope type declarations ****************************************************************/
+
+/*** file scope variables ************************************************************************/
+
+static GSList *panel_plugin_registry = NULL;
+
+/*** file scope functions ************************************************************************/
+/* --------------------------------------------------------------------------------------------- */
+
+/* --------------------------------------------------------------------------------------------- */
+/*** public functions ****************************************************************************/
+/* --------------------------------------------------------------------------------------------- */
+
+gboolean
+mc_panel_plugin_add (const mc_panel_plugin_t *plugin)
+{
+ if (plugin == NULL)
+ return FALSE;
+
+ if (plugin->api_version != MC_PANEL_PLUGIN_API_VERSION)
+ {
+ fprintf (stderr, "Panel plugin \"%s\": API version %d, expected %d\n",
+ plugin->name != NULL ? plugin->name : "(null)", plugin->api_version,
+ MC_PANEL_PLUGIN_API_VERSION);
+ return FALSE;
+ }
+
+ if (plugin->name == NULL || plugin->open == NULL || plugin->close == NULL
+ || plugin->get_items == NULL)
+ {
+ fprintf (stderr, "Panel plugin \"%s\": missing required callbacks\n",
+ plugin->name != NULL ? plugin->name : "(null)");
+ return FALSE;
+ }
+
+ // check for duplicate name
+ if (mc_panel_plugin_find_by_name (plugin->name) != NULL)
+ {
+ fprintf (stderr, "Panel plugin \"%s\": already registered\n", plugin->name);
+ return FALSE;
+ }
+
+ panel_plugin_registry = g_slist_append (panel_plugin_registry, (gpointer) plugin);
+ return TRUE;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+const GSList *
+mc_panel_plugin_list (void)
+{
+ return panel_plugin_registry;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+const mc_panel_plugin_t *
+mc_panel_plugin_find_by_name (const char *name)
+{
+ const GSList *iter;
+
+ if (name == NULL)
+ return NULL;
+
+ for (iter = panel_plugin_registry; iter != NULL; iter = g_slist_next (iter))
+ {
+ const mc_panel_plugin_t *p = (const mc_panel_plugin_t *) iter->data;
+
+ if (strcmp (p->name, name) == 0)
+ return p;
+ }
+
+ return NULL;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+const mc_panel_plugin_t *
+mc_panel_plugin_find_by_prefix (const char *prefix)
+{
+ const GSList *iter;
+
+ if (prefix == NULL)
+ return NULL;
+
+ for (iter = panel_plugin_registry; iter != NULL; iter = g_slist_next (iter))
+ {
+ const mc_panel_plugin_t *p = (const mc_panel_plugin_t *) iter->data;
+
+ if (p->prefix != NULL && strcmp (p->prefix, prefix) == 0)
+ return p;
+ }
+
+ return NULL;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+void
+mc_panel_plugins_shutdown (void)
+{
+ g_slist_free (panel_plugin_registry);
+ panel_plugin_registry = NULL;
+}
+
+/* --------------------------------------------------------------------------------------------- */
diff --git a/lib/panel-plugin.h b/lib/panel-plugin.h
new file mode 100644
index 0000000000..f5145082c9
--- /dev/null
+++ b/lib/panel-plugin.h
@@ -0,0 +1,99 @@
+/** \file panel-plugin.h
+ * \brief Header: panel plugin API for third-party panel content providers
+ */
+
+#ifndef MC__PANEL_PLUGIN_H
+#define MC__PANEL_PLUGIN_H
+
+#include "lib/global.h"
+
+/*
+ * Note: get_items callback receives a dir_list* (from src/filemanager/dir.h)
+ * cast to void*. Plugin implementations should include dir.h and cast back.
+ */
+
+/*** typedefs(not structures) and defined constants **********************************************/
+
+#define MC_PANEL_PLUGIN_API_VERSION 1
+#define MC_PANEL_PLUGIN_ENTRY "mc_panel_plugin_register"
+
+/*** enums ***************************************************************************************/
+
+typedef enum
+{
+ MC_PPR_OK = 0,
+ MC_PPR_FAILED = -1,
+ MC_PPR_NOT_SUPPORTED = -2
+} mc_pp_result_t;
+
+typedef enum
+{
+ MC_PPF_NONE = 0,
+ MC_PPF_NAVIGATE = 1 << 0, /* handles chdir/".." */
+ MC_PPF_GET_FILES = 1 << 1, /* can extract files */
+ MC_PPF_DELETE = 1 << 2, /* can delete items */
+ MC_PPF_CUSTOM_TITLE = 1 << 3,
+ MC_PPF_CREATE = 1 << 4 /* supports Shift+F4 (create item) */
+} mc_pp_flags_t;
+
+/*** structures declarations (and typedefs of structures)*****************************************/
+
+/* What mc provides to the plugin */
+typedef struct mc_panel_host_t
+{
+ void (*refresh) (struct mc_panel_host_t *host);
+ void (*set_hint) (struct mc_panel_host_t *host, const char *text);
+ void (*message) (struct mc_panel_host_t *host, int flags, const char *title, const char *text);
+ void (*close_plugin) (struct mc_panel_host_t *host, const char *dir_path);
+ int (*get_marked_count) (struct mc_panel_host_t *host);
+ const GString *(*get_next_marked) (struct mc_panel_host_t *host, int *current);
+ void *host_data; /* opaque, points to WPanel internally */
+} mc_panel_host_t;
+
+/* What the plugin provides (callback table) */
+typedef struct mc_panel_plugin_t
+{
+ int api_version; /* MC_PANEL_PLUGIN_API_VERSION */
+ const char *name; /* "docker", "git-log" */
+ const char *display_name; /* "Docker containers" */
+ const char *proto; /* protocol prefix for panel title, e.g. "HelloWorld" → "HelloWorld:/path" */
+ const char *prefix; /* "docker:" or NULL */
+ mc_pp_flags_t flags;
+
+ /* Required */
+ void *(*open) (mc_panel_host_t *host, const char *open_path);
+ void (*close) (void *plugin_data);
+ /* Populate the panel. @list is a dir_list* (from dir.h).
+ The ".." entry at index 0 is already created by the host;
+ the plugin must NOT add ".." itself — only real items. */
+ mc_pp_result_t (*get_items) (void *plugin_data, void *list /* dir_list* */);
+
+ /* Optional (NULL = not supported) */
+ mc_pp_result_t (*chdir) (void *plugin_data, const char *path);
+ mc_pp_result_t (*enter) (void *plugin_data, const char *fname, const struct stat *st);
+ mc_pp_result_t (*get_local_copy) (void *plugin_data, const char *fname, char **local_path);
+ mc_pp_result_t (*delete_items) (void *plugin_data, const char **names, int count);
+ const char *(*get_title) (void *plugin_data);
+ mc_pp_result_t (*handle_key) (void *plugin_data, int key);
+ mc_pp_result_t (*create_item) (void *plugin_data);
+} mc_panel_plugin_t;
+
+typedef const mc_panel_plugin_t *(*mc_panel_plugin_register_fn) (void);
+
+/*** global variables defined in .c file *********************************************************/
+
+/*** declarations of public functions ************************************************************/
+
+/* Registry */
+gboolean mc_panel_plugin_add (const mc_panel_plugin_t *plugin);
+const GSList *mc_panel_plugin_list (void);
+const mc_panel_plugin_t *mc_panel_plugin_find_by_name (const char *name);
+const mc_panel_plugin_t *mc_panel_plugin_find_by_prefix (const char *prefix);
+
+/* Loader */
+void mc_panel_plugins_load (void);
+void mc_panel_plugins_shutdown (void);
+
+/*** inline functions ****************************************************************************/
+
+#endif /* MC__PANEL_PLUGIN_H */
diff --git a/lib/stdckdint.h b/lib/stdckdint.h
new file mode 100644
index 0000000000..8814d5efe7
--- /dev/null
+++ b/lib/stdckdint.h
@@ -0,0 +1,36 @@
+/* DO NOT EDIT! GENERATED AUTOMATICALLY! */
+/* stdckdint.h -- checked integer arithmetic
+
+ Copyright 2022-2024 Free Software Foundation, Inc.
+
+ This program is free software: you can redistribute it and/or modify it
+ under the terms of the GNU Lesser General Public License as published
+ by the Free Software Foundation; either version 2.1 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Lesser General Public License for more details.
+
+ You should have received a copy of the GNU Lesser General Public License
+ along with this program. If not, see . */
+
+#ifndef _GL_STDCKDINT_H
+#define _GL_STDCKDINT_H
+
+#include "intprops-internal.h"
+
+/* Store into *R the low-order bits of A + B, A - B, A * B, respectively.
+ Return 1 if the result overflows, 0 otherwise.
+ A, B, and *R can have any integer type other than char, gboolean, a
+ bit-precise integer type, or an enumeration type.
+
+ These are like the standard macros introduced in C23, except that
+ arguments should not have side effects. */
+
+#define ckd_add(r, a, b) ((gboolean) _GL_INT_ADD_WRAPV (a, b, r))
+#define ckd_sub(r, a, b) ((gboolean) _GL_INT_SUBTRACT_WRAPV (a, b, r))
+#define ckd_mul(r, a, b) ((gboolean) _GL_INT_MULTIPLY_WRAPV (a, b, r))
+
+#endif
diff --git a/lib/vfs/Makefile.am b/lib/vfs/Makefile.am
index 66fc05c715..0a6c807345 100644
--- a/lib/vfs/Makefile.am
+++ b/lib/vfs/Makefile.am
@@ -1,6 +1,8 @@
noinst_LTLIBRARIES = libmcvfs.la
-AM_CPPFLAGS = $(GLIB_CFLAGS) -I$(top_srcdir)
+AM_CPPFLAGS = \
+ -DMC_VFS_PLUGINS_DIR=\""$(vfs_plugins_dir)"\" \
+ $(GLIB_CFLAGS) $(GMODULE_CFLAGS) -I$(top_srcdir)
libmcvfs_la_SOURCES = \
direntry.c \
@@ -8,6 +10,7 @@ libmcvfs_la_SOURCES = \
interface.c \
parse_ls_vga.c \
path.c path.h \
+ plugin-loader.c \
vfs.c vfs.h \
utilvfs.c utilvfs.h \
xdirentry.h
diff --git a/lib/vfs/README b/lib/vfs/README
index 036949eba4..dad9a5a218 100644
--- a/lib/vfs/README
+++ b/lib/vfs/README
@@ -86,15 +86,29 @@ a lot of such code in vfs.c.
Hierarchy of classes
====================
-vfs ---- direntry ---- cpio } archives
- | | ---- tar }
+vfs ---- direntry ---- cpio } archives
+ | | ---- tar }
| |
- | | ---- fish } remote systems
- | | ---- ftpfs }
+ | | ---- fish } remote systems
+ | | ---- ftpfs }
+ | | ---- sftpfs } (dynamic plugin, loaded at runtime)
|
|---- extfs ---- extfs archives
|---- localfs ---- sfs ---- sfs archives
+Dynamic plugins
+===============
+
+Some VFS modules are built as shared objects (.so) and loaded at
+runtime from the VFS plugins directory (see --with-vfs-plugins-dir
+configure option). Each plugin exports the mc_vfs_plugin_init symbol
+which is called by the plugin loader after dlopen. This lets
+distributions package optional VFS modules (and their dependencies)
+separately from the core mc binary.
+
+Currently built as dynamic plugins:
+ sftpfs - SFTP filesystem (requires libssh2)
+
Properties of classes
=====================
@@ -106,6 +120,7 @@ cpio yes* yes* no yes
tar yes* yes* no yes
fish no yes yes no
ftpfs no yes yes no
+sftpfs no yes yes no
extfs no no yes yes
localfs no no N/A N/A
sfs no yes yes N/A
diff --git a/lib/vfs/plugin-loader.c b/lib/vfs/plugin-loader.c
new file mode 100644
index 0000000000..6533329e0a
--- /dev/null
+++ b/lib/vfs/plugin-loader.c
@@ -0,0 +1,144 @@
+/*
+ Dynamic VFS plugin loader.
+
+ Copyright (C) 2011-2025
+ Free Software Foundation, Inc.
+
+ This file is part of the Midnight Commander.
+
+ The Midnight Commander is free software: you can redistribute it
+ and/or modify it under the terms of the GNU General Public License as
+ published by the Free Software Foundation, either version 3 of the License,
+ or (at your option) any later version.
+
+ The Midnight Commander is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+ */
+
+/** \file
+ * \brief Dynamic VFS plugin loader
+ *
+ * Scans MC_VFS_PLUGINS_DIR for shared objects exporting mc_vfs_plugin_init(),
+ * loads them, and calls the init function so the plugin can register its
+ * vfs_class via vfs_register_class().
+ */
+
+#include
+
+#include "lib/global.h"
+#include "lib/vfs/vfs.h"
+
+#ifdef HAVE_GMODULE
+
+#include
+#include
+
+/*** global variables ****************************************************************************/
+
+/*** file scope macro definitions ****************************************************************/
+
+/*** file scope type declarations ****************************************************************/
+
+/*** file scope variables ************************************************************************/
+
+static GPtrArray *dynamic_modules = NULL;
+
+/*** file scope functions ************************************************************************/
+/* --------------------------------------------------------------------------------------------- */
+
+/* --------------------------------------------------------------------------------------------- */
+/*** public functions ****************************************************************************/
+/* --------------------------------------------------------------------------------------------- */
+
+void
+vfs_plugins_load_dynamic (void)
+{
+ GDir *dir;
+ const gchar *filename;
+ gchar *plugins_dir;
+
+ plugins_dir = g_build_filename (MC_VFS_PLUGINS_DIR, (char *) NULL);
+ dir = g_dir_open (plugins_dir, 0, NULL);
+ if (dir == NULL)
+ {
+ g_free (plugins_dir);
+ return; // no plugins dir -- OK, nothing to load
+ }
+
+ dynamic_modules = g_ptr_array_new ();
+
+ while ((filename = g_dir_read_name (dir)) != NULL)
+ {
+ GModule *module;
+ mc_vfs_plugin_init_fn init_fn;
+ gchar *path;
+
+ if (!g_str_has_suffix (filename, "." G_MODULE_SUFFIX))
+ continue;
+
+ path = g_build_filename (plugins_dir, filename, (char *) NULL);
+ module = g_module_open (path, G_MODULE_BIND_LAZY);
+ if (module == NULL)
+ {
+ fprintf (stderr, "VFS plugin %s: %s\n", filename, g_module_error ());
+ g_free (path);
+ continue;
+ }
+
+ if (!g_module_symbol (module, MC_VFS_PLUGIN_ENTRY, (gpointer *) &init_fn))
+ {
+ fprintf (stderr, "VFS plugin %s: symbol %s not found\n",
+ filename, MC_VFS_PLUGIN_ENTRY);
+ g_module_close (module);
+ g_free (path);
+ continue;
+ }
+
+ init_fn ();
+ g_module_make_resident (module); // prevent unload -- vfs_class lives in .so
+ g_ptr_array_add (dynamic_modules, module);
+ g_free (path);
+ }
+
+ g_dir_close (dir);
+ g_free (plugins_dir);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+void
+vfs_plugins_unload_dynamic (void)
+{
+ if (dynamic_modules != NULL)
+ {
+ g_ptr_array_free (dynamic_modules, TRUE);
+ dynamic_modules = NULL;
+ }
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+#else /* !HAVE_GMODULE */
+
+void
+vfs_plugins_load_dynamic (void)
+{
+ // GModule not available -- dynamic VFS plugins disabled
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+void
+vfs_plugins_unload_dynamic (void)
+{
+ // nothing to do
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+#endif /* HAVE_GMODULE */
diff --git a/lib/vfs/vfs.h b/lib/vfs/vfs.h
index 5497e06dc7..8e1daf38ab 100644
--- a/lib/vfs/vfs.h
+++ b/lib/vfs/vfs.h
@@ -89,6 +89,9 @@ typedef void (*fill_names_f) (const char *);
typedef void *vfsid;
+typedef void (*mc_vfs_plugin_init_fn) (void);
+#define MC_VFS_PLUGIN_ENTRY "mc_vfs_plugin_init"
+
#ifdef HAVE_UTIMENSAT
typedef struct timespec mc_timesbuf_t[2];
#else
@@ -342,6 +345,10 @@ int mc_mkstemps (vfs_path_t **pname_vpath, const char *prefix, const char *suffi
/* Creating temporary files safely */
const char *mc_tmpdir (void);
+/* Dynamic VFS plugin loader */
+void vfs_plugins_load_dynamic (void);
+void vfs_plugins_unload_dynamic (void);
+
/*** inline functions ****************************************************************************/
#endif
diff --git a/m4.include/vfs/mc-vfs-sftp.m4 b/m4.include/vfs/mc-vfs-sftp.m4
index 581b144b79..7c48c987eb 100644
--- a/m4.include/vfs/mc-vfs-sftp.m4
+++ b/m4.include/vfs/mc-vfs-sftp.m4
@@ -8,7 +8,6 @@ AC_DEFUN([mc_VFS_SFTP],
if test x"$found_libssh" = "xyes"; then
mc_VFS_ADDNAME([sftp])
AC_DEFINE([ENABLE_VFS_SFTP], [1], [Support for SFTP filesystem])
- MCLIBS="$MCLIBS $LIBSSH_LIBS"
enable_vfs_sftp="yes"
else
if test x"$enable_vfs_sftp" = x"yes"; then
diff --git a/misc/skins/README.txt b/misc/skins/README.txt
index c6e21fe57a..7091aec7fb 100644
--- a/misc/skins/README.txt
+++ b/misc/skins/README.txt
@@ -396,7 +396,13 @@ display. The characters in the skin files need to be encoded in UTF-8.
Maximize or unmaximize an editor window
window-close-char
- Close an exitor window
+ Close an editor window
+
+ fold-open-char
+ Indicator for an open (expanded) fold in the editor
+
+ fold-close-char
+ Indicator for a closed (collapsed) fold in the editor
Aliases section
diff --git a/misc/skins/dark.ini b/misc/skins/dark.ini
index afae6cb6cf..44fd1b5f1a 100644
--- a/misc/skins/dark.ini
+++ b/misc/skins/dark.ini
@@ -161,3 +161,5 @@
[widget-editor]
window-state-char = ↕
window-close-char = ✕
+ fold-open-char = ▾
+ fold-close-char = ▸
diff --git a/misc/skins/darkfar.ini b/misc/skins/darkfar.ini
index cd72255f75..b919d9fb94 100644
--- a/misc/skins/darkfar.ini
+++ b/misc/skins/darkfar.ini
@@ -161,3 +161,5 @@
[widget-editor]
window-state-char = ↕
window-close-char = ×
+ fold-open-char = ▾
+ fold-close-char = ▸
diff --git a/misc/skins/default.ini b/misc/skins/default.ini
index 6c74bbf6de..a8784c80e1 100644
--- a/misc/skins/default.ini
+++ b/misc/skins/default.ini
@@ -161,3 +161,5 @@
[widget-editor]
window-state-char = *
window-close-char = X
+ fold-open-char = v
+ fold-close-char = >
diff --git a/misc/skins/double-lines.ini b/misc/skins/double-lines.ini
index 28fa680408..6d0a06a04a 100644
--- a/misc/skins/double-lines.ini
+++ b/misc/skins/double-lines.ini
@@ -161,3 +161,5 @@
[widget-editor]
window-state-char = *
window-close-char = X
+ fold-open-char = v
+ fold-close-char = >
diff --git a/misc/skins/featured-plus.ini b/misc/skins/featured-plus.ini
index 1d048e6e03..46885f5c37 100644
--- a/misc/skins/featured-plus.ini
+++ b/misc/skins/featured-plus.ini
@@ -161,3 +161,5 @@
[widget-editor]
window-state-char = ↕
window-close-char = ✕
+ fold-open-char = ▾
+ fold-close-char = ▸
diff --git a/misc/skins/featured.ini b/misc/skins/featured.ini
index 3e07d22f3b..e9bef695cd 100644
--- a/misc/skins/featured.ini
+++ b/misc/skins/featured.ini
@@ -161,3 +161,5 @@
[widget-editor]
window-state-char = ↕
window-close-char = ✕
+ fold-open-char = ▾
+ fold-close-char = ▸
diff --git a/misc/skins/gotar.ini b/misc/skins/gotar.ini
index a4ca95b6b0..4224478a3b 100644
--- a/misc/skins/gotar.ini
+++ b/misc/skins/gotar.ini
@@ -159,3 +159,5 @@
[widget-editor]
window-state-char = *
window-close-char = X
+ fold-open-char = v
+ fold-close-char = >
diff --git a/misc/skins/gray-green-purple256.ini b/misc/skins/gray-green-purple256.ini
index 575ec6b0fa..d9c6f1e673 100644
--- a/misc/skins/gray-green-purple256.ini
+++ b/misc/skins/gray-green-purple256.ini
@@ -167,3 +167,5 @@
[widget-editor]
window-state-char = ↕
window-close-char = ✕
+ fold-open-char = ▾
+ fold-close-char = ▸
diff --git a/misc/skins/gray-orange-blue256.ini b/misc/skins/gray-orange-blue256.ini
index 458cc59f19..c57e0e0d12 100644
--- a/misc/skins/gray-orange-blue256.ini
+++ b/misc/skins/gray-orange-blue256.ini
@@ -167,3 +167,5 @@
[widget-editor]
window-state-char = ↕
window-close-char = ✕
+ fold-open-char = ▾
+ fold-close-char = ▸
diff --git a/misc/skins/julia256.ini b/misc/skins/julia256.ini
index 052dd87f86..841ea8aa8c 100644
--- a/misc/skins/julia256.ini
+++ b/misc/skins/julia256.ini
@@ -166,3 +166,5 @@
[widget-editor]
window-state-char = ↕
window-close-char = ✕
+ fold-open-char = ▾
+ fold-close-char = ▸
diff --git a/misc/skins/julia256root.ini b/misc/skins/julia256root.ini
index b25078436e..c6588b6479 100644
--- a/misc/skins/julia256root.ini
+++ b/misc/skins/julia256root.ini
@@ -166,3 +166,5 @@
[widget-editor]
window-state-char = ↕
window-close-char = ✕
+ fold-open-char = ▾
+ fold-close-char = ▸
diff --git a/misc/skins/mc46.ini b/misc/skins/mc46.ini
index 0fa6ff5a4d..6ddaaa2fb0 100644
--- a/misc/skins/mc46.ini
+++ b/misc/skins/mc46.ini
@@ -152,3 +152,5 @@
[widget-editor]
window-state-char = *
window-close-char = X
+ fold-open-char = v
+ fold-close-char = >
diff --git a/misc/skins/modarcon16-defbg-thin.ini b/misc/skins/modarcon16-defbg-thin.ini
index 31cc9f7edd..d1f149c060 100644
--- a/misc/skins/modarcon16-defbg-thin.ini
+++ b/misc/skins/modarcon16-defbg-thin.ini
@@ -197,3 +197,5 @@
[widget-editor]
window-state-char = ↕
window-close-char = ✕
+ fold-open-char = ▾
+ fold-close-char = ▸
diff --git a/misc/skins/modarcon16-defbg.ini b/misc/skins/modarcon16-defbg.ini
index 7d90991e19..7055a14b6e 100644
--- a/misc/skins/modarcon16-defbg.ini
+++ b/misc/skins/modarcon16-defbg.ini
@@ -199,3 +199,5 @@
[widget-editor]
window-state-char = ↕
window-close-char = ✕
+ fold-open-char = ▾
+ fold-close-char = ▸
diff --git a/misc/skins/modarcon16-thin.ini b/misc/skins/modarcon16-thin.ini
index 0986454545..3840b44622 100644
--- a/misc/skins/modarcon16-thin.ini
+++ b/misc/skins/modarcon16-thin.ini
@@ -197,3 +197,5 @@
[widget-editor]
window-state-char = ↕
window-close-char = ✕
+ fold-open-char = ▾
+ fold-close-char = ▸
diff --git a/misc/skins/modarcon16.ini b/misc/skins/modarcon16.ini
index 60fc0e1f74..b3c0ca1a3b 100644
--- a/misc/skins/modarcon16.ini
+++ b/misc/skins/modarcon16.ini
@@ -199,3 +199,5 @@
[widget-editor]
window-state-char = ↕
window-close-char = ✕
+ fold-open-char = ▾
+ fold-close-char = ▸
diff --git a/misc/skins/modarcon16root-defbg-thin.ini b/misc/skins/modarcon16root-defbg-thin.ini
index 76ba02063a..5a3c875299 100644
--- a/misc/skins/modarcon16root-defbg-thin.ini
+++ b/misc/skins/modarcon16root-defbg-thin.ini
@@ -197,3 +197,5 @@
[widget-editor]
window-state-char = ↕
window-close-char = ✕
+ fold-open-char = ▾
+ fold-close-char = ▸
diff --git a/misc/skins/modarcon16root-defbg.ini b/misc/skins/modarcon16root-defbg.ini
index 775dd54452..ee5fd54b3c 100644
--- a/misc/skins/modarcon16root-defbg.ini
+++ b/misc/skins/modarcon16root-defbg.ini
@@ -199,3 +199,5 @@
[widget-editor]
window-state-char = ↕
window-close-char = ✕
+ fold-open-char = ▾
+ fold-close-char = ▸
diff --git a/misc/skins/modarcon16root-thin.ini b/misc/skins/modarcon16root-thin.ini
index 0918d2bd3b..c0a667b85b 100644
--- a/misc/skins/modarcon16root-thin.ini
+++ b/misc/skins/modarcon16root-thin.ini
@@ -197,3 +197,5 @@
[widget-editor]
window-state-char = ↕
window-close-char = ✕
+ fold-open-char = ▾
+ fold-close-char = ▸
diff --git a/misc/skins/modarcon16root.ini b/misc/skins/modarcon16root.ini
index 0fc9e3f537..312ddd4d9a 100644
--- a/misc/skins/modarcon16root.ini
+++ b/misc/skins/modarcon16root.ini
@@ -199,3 +199,5 @@
[widget-editor]
window-state-char = ↕
window-close-char = ✕
+ fold-open-char = ▾
+ fold-close-char = ▸
diff --git a/misc/skins/modarin256-defbg-thin.ini b/misc/skins/modarin256-defbg-thin.ini
index 72281b9b0c..7f99976e66 100644
--- a/misc/skins/modarin256-defbg-thin.ini
+++ b/misc/skins/modarin256-defbg-thin.ini
@@ -197,3 +197,5 @@
[widget-editor]
window-state-char = ↕
window-close-char = ✕
+ fold-open-char = ▾
+ fold-close-char = ▸
diff --git a/misc/skins/modarin256-defbg.ini b/misc/skins/modarin256-defbg.ini
index bf677bd60b..6055b9b080 100644
--- a/misc/skins/modarin256-defbg.ini
+++ b/misc/skins/modarin256-defbg.ini
@@ -199,3 +199,5 @@
[widget-editor]
window-state-char = ↕
window-close-char = ✕
+ fold-open-char = ▾
+ fold-close-char = ▸
diff --git a/misc/skins/modarin256-thin.ini b/misc/skins/modarin256-thin.ini
index 3f822f12d2..f73a24994e 100644
--- a/misc/skins/modarin256-thin.ini
+++ b/misc/skins/modarin256-thin.ini
@@ -197,3 +197,5 @@
[widget-editor]
window-state-char = ↕
window-close-char = ✕
+ fold-open-char = ▾
+ fold-close-char = ▸
diff --git a/misc/skins/modarin256.ini b/misc/skins/modarin256.ini
index 317e3ca2ec..2269edb551 100644
--- a/misc/skins/modarin256.ini
+++ b/misc/skins/modarin256.ini
@@ -199,3 +199,5 @@
[widget-editor]
window-state-char = ↕
window-close-char = ✕
+ fold-open-char = ▾
+ fold-close-char = ▸
diff --git a/misc/skins/modarin256root-defbg-thin.ini b/misc/skins/modarin256root-defbg-thin.ini
index 73d96d3eda..50c497a7a2 100644
--- a/misc/skins/modarin256root-defbg-thin.ini
+++ b/misc/skins/modarin256root-defbg-thin.ini
@@ -197,3 +197,5 @@
[widget-editor]
window-state-char = ↕
window-close-char = ✕
+ fold-open-char = ▾
+ fold-close-char = ▸
diff --git a/misc/skins/modarin256root-defbg.ini b/misc/skins/modarin256root-defbg.ini
index 2aa2df2b82..49b2938703 100644
--- a/misc/skins/modarin256root-defbg.ini
+++ b/misc/skins/modarin256root-defbg.ini
@@ -199,3 +199,5 @@
[widget-editor]
window-state-char = ↕
window-close-char = ✕
+ fold-open-char = ▾
+ fold-close-char = ▸
diff --git a/misc/skins/modarin256root-thin.ini b/misc/skins/modarin256root-thin.ini
index 3efbb1dd6e..bb5c4440e7 100644
--- a/misc/skins/modarin256root-thin.ini
+++ b/misc/skins/modarin256root-thin.ini
@@ -197,3 +197,5 @@
[widget-editor]
window-state-char = ↕
window-close-char = ✕
+ fold-open-char = ▾
+ fold-close-char = ▸
diff --git a/misc/skins/modarin256root.ini b/misc/skins/modarin256root.ini
index 2773dddf77..0728f3f76b 100644
--- a/misc/skins/modarin256root.ini
+++ b/misc/skins/modarin256root.ini
@@ -199,3 +199,5 @@
[widget-editor]
window-state-char = ↕
window-close-char = ✕
+ fold-open-char = ▾
+ fold-close-char = ▸
diff --git a/misc/skins/nicedark.ini b/misc/skins/nicedark.ini
index 1b6d5e1446..eea9617885 100644
--- a/misc/skins/nicedark.ini
+++ b/misc/skins/nicedark.ini
@@ -161,3 +161,5 @@
[widget-editor]
window-state-char = ↕
window-close-char = ✕
+ fold-open-char = ▾
+ fold-close-char = ▸
diff --git a/misc/skins/sand256.ini b/misc/skins/sand256.ini
index 1ee31c9064..60fc2af48b 100644
--- a/misc/skins/sand256.ini
+++ b/misc/skins/sand256.ini
@@ -163,3 +163,5 @@
[widget-editor]
window-state-char = ↕
window-close-char = ✕
+ fold-open-char = ▾
+ fold-close-char = ▸
diff --git a/misc/skins/seasons-autumn16M.ini b/misc/skins/seasons-autumn16M.ini
index 622f0273b3..8dd7a31465 100644
--- a/misc/skins/seasons-autumn16M.ini
+++ b/misc/skins/seasons-autumn16M.ini
@@ -203,3 +203,5 @@
[widget-editor]
window-state-char = ↕
window-close-char = ✕
+ fold-open-char = ▾
+ fold-close-char = ▸
diff --git a/misc/skins/seasons-spring16M.ini b/misc/skins/seasons-spring16M.ini
index ab959c791e..5de23f7c1e 100644
--- a/misc/skins/seasons-spring16M.ini
+++ b/misc/skins/seasons-spring16M.ini
@@ -203,3 +203,5 @@
[widget-editor]
window-state-char = ↕
window-close-char = ✕
+ fold-open-char = ▾
+ fold-close-char = ▸
diff --git a/misc/skins/seasons-summer16M.ini b/misc/skins/seasons-summer16M.ini
index 31c7158aaa..93b7acaeff 100644
--- a/misc/skins/seasons-summer16M.ini
+++ b/misc/skins/seasons-summer16M.ini
@@ -203,3 +203,5 @@
[widget-editor]
window-state-char = ↕
window-close-char = ✕
+ fold-open-char = ▾
+ fold-close-char = ▸
diff --git a/misc/skins/seasons-winter16M.ini b/misc/skins/seasons-winter16M.ini
index 19b1275a1d..eea7650fbc 100644
--- a/misc/skins/seasons-winter16M.ini
+++ b/misc/skins/seasons-winter16M.ini
@@ -203,3 +203,5 @@
[widget-editor]
window-state-char = ↕
window-close-char = ✕
+ fold-open-char = ▾
+ fold-close-char = ▸
diff --git a/misc/skins/xoria256-thin.ini b/misc/skins/xoria256-thin.ini
index c1fabaef8c..8ff7f84925 100644
--- a/misc/skins/xoria256-thin.ini
+++ b/misc/skins/xoria256-thin.ini
@@ -176,3 +176,5 @@
[widget-editor]
window-state-char = ↕
window-close-char = ✕
+ fold-open-char = ▾
+ fold-close-char = ▸
diff --git a/misc/skins/xoria256.ini b/misc/skins/xoria256.ini
index 7772dc983b..75f3c866be 100644
--- a/misc/skins/xoria256.ini
+++ b/misc/skins/xoria256.ini
@@ -176,3 +176,5 @@
[widget-editor]
window-state-char = ↕
window-close-char = ✕
+ fold-open-char = ▾
+ fold-close-char = ▸
diff --git a/misc/skins/xoria256root-thin.ini b/misc/skins/xoria256root-thin.ini
index 73615942b4..82f7f382d5 100644
--- a/misc/skins/xoria256root-thin.ini
+++ b/misc/skins/xoria256root-thin.ini
@@ -176,3 +176,5 @@
[widget-editor]
window-state-char = ↕
window-close-char = ✕
+ fold-open-char = ▾
+ fold-close-char = ▸
diff --git a/misc/skins/yadt256-defbg.ini b/misc/skins/yadt256-defbg.ini
index 43b6aa1721..0ce86b6e4e 100644
--- a/misc/skins/yadt256-defbg.ini
+++ b/misc/skins/yadt256-defbg.ini
@@ -166,3 +166,5 @@
[widget-editor]
window-state-char = ↕
window-close-char = ✕
+ fold-open-char = ▾
+ fold-close-char = ▸
diff --git a/misc/skins/yadt256.ini b/misc/skins/yadt256.ini
index 26b88e3591..a8eec4ed72 100644
--- a/misc/skins/yadt256.ini
+++ b/misc/skins/yadt256.ini
@@ -165,3 +165,5 @@
[widget-editor]
window-state-char = ↕
window-close-char = ✕
+ fold-open-char = ▾
+ fold-close-char = ▸
diff --git a/src/Makefile.am b/src/Makefile.am
index dbad42ad60..c7df3f3ce2 100644
--- a/src/Makefile.am
+++ b/src/Makefile.am
@@ -1,4 +1,4 @@
-SUBDIRS = filemanager man2hlp vfs viewer
+SUBDIRS = filemanager man2hlp vfs viewer panel-plugins
if USE_INTERNAL_EDIT
SUBDIRS += editor
@@ -26,13 +26,6 @@ SUBDIRS += consaver
AM_CPPFLAGS += -DSAVERDIR=\""$(pkglibexecdir)"\"
endif
-# required for textconf.c
-if ENABLE_VFS
-if ENABLE_VFS_SFTP
-AM_CPPFLAGS += $(LIBSSH_CFLAGS)
-endif
-endif
-
AM_CPPFLAGS += -I$(top_srcdir) $(GLIB_CFLAGS)
bin_PROGRAMS = mc
@@ -55,6 +48,10 @@ libinternal_la_LIBADD = \
viewer/libmcviewer.la \
$(DIFFLIB) $(EDITLIB) $(SUBSHELLLIB)
+if HAVE_GMODULE
+mc_LDFLAGS = -export-dynamic
+endif
+
mc_LDADD = \
libinternal.la
diff --git a/src/editor/Makefile.am b/src/editor/Makefile.am
index 304cb35be9..e98759178d 100644
--- a/src/editor/Makefile.am
+++ b/src/editor/Makefile.am
@@ -14,6 +14,7 @@ libedit_la_SOURCES = \
editbuffer.c editbuffer.h \
editcmd.c \
editdraw.c \
+ fold.c \
editmacros.c editmacros.h \
editmenu.c \
editoptions.c \
diff --git a/src/editor/edit-impl.h b/src/editor/edit-impl.h
index d8662b8738..6e6a4910e1 100644
--- a/src/editor/edit-impl.h
+++ b/src/editor/edit-impl.h
@@ -67,7 +67,7 @@
/* max count stack files */
#define MAX_HISTORY_MOVETO 50
-#define LINE_STATE_WIDTH 8
+#define LINE_STATE_WIDTH 9
#define LB_NAMES (LB_MAC + 1)
@@ -110,6 +110,8 @@ extern gboolean search_create_bookmark;
extern char *edit_window_state_char;
extern char *edit_window_close_char;
+extern char *edit_fold_open_char;
+extern char *edit_fold_close_char;
/*** declarations of public functions ************************************************************/
@@ -225,6 +227,20 @@ void book_mark_dec (WEdit *edit, long line);
void book_mark_serialize (WEdit *edit, int color);
void book_mark_restore (WEdit *edit, int color);
+off_t edit_get_bracket (WEdit *edit, gboolean in_screen, unsigned long furthest_bracket_search);
+
+struct edit_fold_t *edit_fold_find (WEdit *edit, long line);
+gboolean edit_fold_is_hidden (WEdit *edit, long line);
+void edit_fold_make (WEdit *edit, long line_start, long line_count);
+gboolean edit_fold_remove (WEdit *edit, long line);
+long edit_fold_next_visible (WEdit *edit, long line);
+long edit_fold_prev_visible (WEdit *edit, long line);
+void edit_fold_flush (WEdit *edit);
+void edit_fold_inc (WEdit *edit, long line);
+void edit_fold_dec (WEdit *edit, long line);
+void edit_fold_toggle (WEdit *edit);
+int edit_fold_indicator_width (const struct edit_fold_t *fold);
+
gboolean edit_line_is_blank (WEdit *edit, long line);
gboolean is_break_char (char c);
void edit_options_dialog (WDialog *h);
diff --git a/src/editor/edit.c b/src/editor/edit.c
index 4f040db90a..464c6544a4 100644
--- a/src/editor/edit.c
+++ b/src/editor/edit.c
@@ -884,6 +884,16 @@ edit_cursor_to_eol (WEdit *edit)
edit->search_start = edit->buffer.curs1;
edit->prev_col = edit_get_col (edit);
edit->over_col = 0;
+
+ /* On a fold start line, End should place cursor after the fold indicator */
+ if (edit->folds != NULL)
+ {
+ edit_fold_t *fold;
+
+ fold = edit_fold_find (edit, edit->buffer.curs_line);
+ if (fold != NULL && edit->buffer.curs_line == fold->line_start)
+ edit->over_col = edit_fold_indicator_width (fold);
+ }
}
/* --------------------------------------------------------------------------------------------- */
@@ -1025,10 +1035,94 @@ edit_right_char_move_cmd (WEdit *edit)
else
c = edit_buffer_get_current_byte (&edit->buffer);
+ /* On a fold start line at or past the opening bracket: skip over fold indicator */
+ if (edit->folds != NULL)
+ {
+ edit_fold_t *fold;
+
+ fold = edit_fold_find (edit, edit->buffer.curs_line);
+ if (fold != NULL && edit->buffer.curs_line == fold->line_start)
+ {
+ off_t bol, eol, bracket_off;
+
+ bol = edit_buffer_get_current_bol (&edit->buffer);
+ eol = edit_buffer_get_current_eol (&edit->buffer);
+
+ /* find the opening bracket by scanning backward from EOL */
+ for (bracket_off = eol - 1; bracket_off >= bol; bracket_off--)
+ {
+ int ch;
+
+ ch = edit_buffer_get_byte (&edit->buffer, bracket_off);
+ if (ch == '{' || ch == '[' || ch == '(')
+ break;
+ }
+
+ if (bracket_off >= bol && edit->buffer.curs1 >= bracket_off)
+ {
+ if (edit_options.cursor_beyond_eol)
+ {
+ int fold_width;
+
+ fold_width = edit_fold_indicator_width (fold);
+
+ /* move cursor to EOL if not already there */
+ if (edit->buffer.curs1 < eol)
+ {
+ edit_cursor_move (edit, eol - edit->buffer.curs1);
+ edit->over_col = 0;
+ }
+
+ if (edit->over_col < fold_width)
+ {
+ /* jump over fold indicator like over a tab */
+ edit->over_col = fold_width;
+ }
+ else
+ {
+ /* past the fold indicator — keep going right */
+ edit->over_col++;
+ }
+ return;
+ }
+
+ /* cursor_beyond_eol off: jump to next visible line */
+ {
+ off_t target;
+
+ target = edit_buffer_get_forward_offset (&edit->buffer, bol,
+ fold->line_count + 1, 0);
+ edit_cursor_move (edit, target - edit->buffer.curs1);
+ edit->over_col = 0;
+ }
+ return;
+ }
+ }
+ }
+
if (edit_options.cursor_beyond_eol && c == '\n')
edit->over_col++;
else
edit_cursor_move (edit, char_length);
+
+ /* Skip over hidden (folded) lines — fallback for other entry points */
+ if (edit->folds != NULL && edit_fold_is_hidden (edit, edit->buffer.curs_line))
+ {
+ edit_fold_t *fold;
+
+ fold = edit_fold_find (edit, edit->buffer.curs_line);
+ if (fold != NULL)
+ {
+ long delta;
+ off_t target;
+
+ delta = fold->line_start + fold->line_count + 1 - edit->buffer.curs_line;
+ target = edit_buffer_get_forward_offset (
+ &edit->buffer, edit_buffer_get_current_bol (&edit->buffer), delta, 0);
+ edit_cursor_move (edit, target - edit->buffer.curs1);
+ edit->over_col = 0;
+ }
+ }
}
/* --------------------------------------------------------------------------------------------- */
@@ -1050,9 +1144,94 @@ edit_left_char_move_cmd (WEdit *edit)
}
if (edit_options.cursor_beyond_eol && edit->over_col > 0)
+ {
+ /* On a fold start line, jump over the fold indicator in one step */
+ if (edit->folds != NULL)
+ {
+ edit_fold_t *fold;
+
+ fold = edit_fold_find (edit, edit->buffer.curs_line);
+ if (fold != NULL && edit->buffer.curs_line == fold->line_start)
+ {
+ int fold_width;
+
+ fold_width = edit_fold_indicator_width (fold);
+ if (edit->over_col <= fold_width)
+ {
+ off_t bol, eol, bracket_off;
+
+ bol = edit_buffer_get_current_bol (&edit->buffer);
+ eol = edit_buffer_get_current_eol (&edit->buffer);
+
+ /* find the opening bracket */
+ for (bracket_off = eol - 1; bracket_off >= bol; bracket_off--)
+ {
+ int ch;
+
+ ch = edit_buffer_get_byte (&edit->buffer, bracket_off);
+ if (ch == '{' || ch == '[' || ch == '(')
+ break;
+ }
+
+ if (bracket_off >= bol)
+ edit_cursor_move (edit, bracket_off - edit->buffer.curs1);
+
+ edit->over_col = 0;
+ return;
+ }
+ }
+ }
edit->over_col--;
+ }
else
edit_cursor_move (edit, -char_length);
+
+ /* Skip over hidden (folded) lines */
+ if (edit->folds != NULL && edit_fold_is_hidden (edit, edit->buffer.curs_line))
+ {
+ edit_fold_t *fold;
+
+ fold = edit_fold_find (edit, edit->buffer.curs_line);
+ if (fold != NULL)
+ {
+ long delta;
+ off_t bol, eol;
+
+ /* Jump to fold start line */
+ delta = edit->buffer.curs_line - fold->line_start;
+ bol = edit_buffer_get_backward_offset (
+ &edit->buffer, edit_buffer_get_current_bol (&edit->buffer), delta);
+ eol = edit_buffer_get_eol (&edit->buffer, bol);
+
+ if (edit_options.cursor_beyond_eol)
+ {
+ /* cursor_beyond_eol on: land at end of fold indicator */
+ edit_cursor_move (edit, eol - edit->buffer.curs1);
+ edit->over_col = edit_fold_indicator_width (fold);
+ }
+ else
+ {
+ off_t bracket_off;
+
+ /* cursor_beyond_eol off: land on the opening bracket */
+ for (bracket_off = eol - 1; bracket_off >= bol; bracket_off--)
+ {
+ int ch;
+
+ ch = edit_buffer_get_byte (&edit->buffer, bracket_off);
+ if (ch == '{' || ch == '[' || ch == '(')
+ break;
+ }
+
+ if (bracket_off >= bol)
+ edit_cursor_move (edit, bracket_off - edit->buffer.curs1);
+ else
+ edit_cursor_move (edit, eol - edit->buffer.curs1);
+
+ edit->over_col = 0;
+ }
+ }
+ }
}
/* --------------------------------------------------------------------------------------------- */
@@ -1075,6 +1254,8 @@ edit_move_updown (WEdit *edit, long lines, gboolean do_scroll, gboolean directio
if (lines > 1)
edit->force |= REDRAW_PAGE;
+ if (edit->folds != NULL)
+ edit->force |= REDRAW_PAGE;
if (do_scroll)
{
if (direction)
@@ -1088,6 +1269,38 @@ edit_move_updown (WEdit *edit, long lines, gboolean do_scroll, gboolean directio
edit_cursor_move (edit, p - edit->buffer.curs1);
edit_move_to_prev_col (edit, p);
+ /* skip over folded (hidden) lines */
+ if (edit->folds != NULL)
+ {
+ edit_fold_t *fold;
+
+ fold = edit_fold_find (edit, edit->buffer.curs_line);
+ if (fold != NULL && edit->buffer.curs_line > fold->line_start
+ && edit->buffer.curs_line <= fold->line_start + fold->line_count)
+ {
+ long target;
+
+ if (direction)
+ target = fold->line_start;
+ else
+ target = fold->line_start + fold->line_count + 1;
+
+ {
+ long delta = target - edit->buffer.curs_line;
+ off_t np;
+
+ if (delta > 0)
+ np = edit_buffer_get_forward_offset (
+ &edit->buffer, edit_buffer_get_current_bol (&edit->buffer), delta, 0);
+ else
+ np = edit_buffer_get_backward_offset (
+ &edit->buffer, edit_buffer_get_current_bol (&edit->buffer), -delta);
+ edit_cursor_move (edit, np - edit->buffer.curs1);
+ edit_move_to_prev_col (edit, np);
+ }
+ }
+ }
+
// search start of current multibyte char (like CJK)
if (edit->buffer.curs1 > 0 && edit->buffer.curs1 + 1 < edit->buffer.size
&& edit_buffer_get_current_byte (&edit->buffer) >= 256)
@@ -1517,7 +1730,7 @@ check_and_wrap_line (WEdit *edit)
* @return position of the found bracket (-1 if no match)
*/
-static off_t
+off_t
edit_get_bracket (WEdit *edit, gboolean in_screen, unsigned long furthest_bracket_search)
{
const char *const b = "{}{[][()(", *p;
@@ -2225,6 +2438,7 @@ edit_clean (WEdit *edit)
edit_free_syntax_rules (edit);
book_mark_flush (edit, -1);
+ edit_fold_flush (edit);
edit_buffer_clean (&edit->buffer);
@@ -2544,6 +2758,7 @@ edit_insert (WEdit *edit, int c)
if (c == '\n')
{
book_mark_inc (edit, edit->buffer.curs_line);
+ edit_fold_inc (edit, edit->buffer.curs_line);
edit->buffer.curs_line++;
edit->buffer.lines++;
edit->force |= REDRAW_LINE_ABOVE | REDRAW_AFTER_CURSOR;
@@ -2579,6 +2794,7 @@ edit_insert_ahead (WEdit *edit, int c)
if (c == '\n')
{
book_mark_inc (edit, edit->buffer.curs_line);
+ edit_fold_inc (edit, edit->buffer.curs_line);
edit->buffer.lines++;
edit->force |= REDRAW_AFTER_CURSOR;
}
@@ -2652,6 +2868,7 @@ edit_delete (WEdit *edit, gboolean byte_delete)
if (p == '\n')
{
book_mark_dec (edit, edit->buffer.curs_line);
+ edit_fold_dec (edit, edit->buffer.curs_line);
edit->buffer.lines--;
edit->force |= REDRAW_AFTER_CURSOR;
}
@@ -2707,6 +2924,7 @@ edit_backspace (WEdit *edit, gboolean byte_delete)
if (p == '\n')
{
book_mark_dec (edit, edit->buffer.curs_line);
+ edit_fold_dec (edit, edit->buffer.curs_line);
edit->buffer.curs_line--;
edit->buffer.lines--;
edit->force |= REDRAW_AFTER_CURSOR;
@@ -2859,7 +3077,31 @@ edit_get_col (const WEdit *edit)
void
edit_update_curs_row (WEdit *edit)
{
- edit->curs_row = edit->buffer.curs_line - edit->start_line;
+ long hidden = 0;
+
+ if (edit->folds != NULL)
+ {
+ edit_fold_t *f;
+
+ for (f = edit->folds; f != NULL; f = f->next)
+ {
+ long h_start, h_end;
+
+ /* hidden lines are [f->line_start + 1 .. f->line_start + f->line_count] */
+ if (f->line_start + f->line_count < edit->start_line)
+ continue;
+ if (f->line_start >= edit->buffer.curs_line)
+ break;
+
+ h_start = MAX (f->line_start + 1, edit->start_line);
+ h_end = MIN (f->line_start + f->line_count, edit->buffer.curs_line - 1);
+
+ if (h_end >= h_start)
+ hidden += h_end - h_start + 1;
+ }
+ }
+
+ edit->curs_row = edit->buffer.curs_line - edit->start_line - hidden;
}
/* --------------------------------------------------------------------------------------------- */
@@ -2899,6 +3141,23 @@ edit_scroll_upward (WEdit *edit, long i)
edit->force |= REDRAW_PAGE;
edit->force &= (0xfff - REDRAW_CHAR_ONLY);
}
+
+ /* if start_line landed inside a fold, move to fold start */
+ if (edit->folds != NULL)
+ {
+ edit_fold_t *f;
+
+ f = edit_fold_find (edit, edit->start_line);
+ if (f != NULL && edit->start_line > f->line_start)
+ {
+ long skip = edit->start_line - f->line_start;
+
+ edit->start_line -= skip;
+ edit->start_display =
+ edit_buffer_get_backward_offset (&edit->buffer, edit->start_display, skip);
+ }
+ }
+
edit_update_curs_row (edit);
}
@@ -2910,6 +3169,20 @@ edit_scroll_downward (WEdit *edit, long i)
long lines_below;
lines_below = edit->buffer.lines - edit->start_line - (WIDGET (edit)->rect.lines - 1);
+
+ /* Each fold in the viewport takes one screen row but covers line_count+1 buffer lines.
+ Add back fold->line_count so lines_below doesn't underestimate. */
+ if (edit->folds != NULL)
+ {
+ edit_fold_t *f;
+
+ for (f = edit->folds; f != NULL; f = f->next)
+ {
+ if (f->line_start >= edit->start_line)
+ lines_below += f->line_count;
+ }
+ }
+
if (lines_below > 0)
{
if (i > lines_below)
@@ -2920,6 +3193,23 @@ edit_scroll_downward (WEdit *edit, long i)
edit->force |= REDRAW_PAGE;
edit->force &= (0xfff - REDRAW_CHAR_ONLY);
}
+
+ /* if start_line landed inside a fold, skip past it */
+ if (edit->folds != NULL)
+ {
+ edit_fold_t *f;
+
+ f = edit_fold_find (edit, edit->start_line);
+ if (f != NULL && edit->start_line > f->line_start)
+ {
+ long skip = f->line_start + f->line_count + 1 - edit->start_line;
+
+ edit->start_line += skip;
+ edit->start_display =
+ edit_buffer_get_forward_offset (&edit->buffer, edit->start_display, skip, 0);
+ }
+ }
+
edit_update_curs_row (edit);
}
@@ -3420,6 +3710,28 @@ edit_execute_cmd (WEdit *edit, long command, int char_for_insertion)
edit->redo_stack_reset = 1;
+ /* Snap cursor out of hidden (folded) lines — like tab-snap prevents mid-tab cursor */
+ if (edit->folds != NULL && edit_fold_is_hidden (edit, edit->buffer.curs_line))
+ {
+ edit_fold_t *fold;
+
+ fold = edit_fold_find (edit, edit->buffer.curs_line);
+ if (fold != NULL)
+ {
+ long delta;
+ off_t bol;
+
+ delta = edit->buffer.curs_line - fold->line_start;
+ bol = edit_buffer_get_backward_offset (
+ &edit->buffer, edit_buffer_get_current_bol (&edit->buffer), delta);
+ edit_cursor_move (edit, bol - edit->buffer.curs1);
+ /* position at EOL of fold start line */
+ edit_cursor_move (edit,
+ edit_buffer_get_current_eol (&edit->buffer) - edit->buffer.curs1);
+ edit->over_col = 0;
+ }
+ }
+
// An ordinary key press
if (char_for_insertion >= 0)
{
@@ -3844,6 +4156,14 @@ edit_execute_cmd (WEdit *edit, long command, int char_for_insertion)
}
break;
+ case CK_FoldToggle:
+ edit_fold_toggle (edit);
+ break;
+ case CK_UnfoldAll:
+ edit_fold_flush (edit);
+ edit->force |= REDRAW_PAGE;
+ break;
+
case CK_Top:
case CK_MarkToFileBegin:
edit_move_to_top (edit);
diff --git a/src/editor/editdraw.c b/src/editor/editdraw.c
index d096829428..1bd57aec9a 100644
--- a/src/editor/editdraw.c
+++ b/src/editor/editdraw.c
@@ -433,6 +433,16 @@ print_to_widget (WEdit *edit, long row, int start_col, int start_col_real, long
edit_move (x1 + i - edit_options.line_state_width, y);
if (status[i] == '\0')
status[i] = ' ';
+ if (i == LINE_STATE_WIDTH - 1 && status[i] == 'v')
+ {
+ tty_print_string (edit_fold_open_char);
+ continue;
+ }
+ if (i == LINE_STATE_WIDTH - 1 && status[i] == '>')
+ {
+ tty_print_string (edit_fold_close_char);
+ continue;
+ }
tty_print_char (status[i]);
}
}
@@ -497,6 +507,8 @@ edit_draw_this_line (WEdit *edit, off_t b, long row, long start_col, long end_co
int col, start_col_real;
int abn_style;
int book_mark = 0;
+ int brace_depth = 0;
+ long fold_line_count = 0;
char line_stat[LINE_STATE_WIDTH + 1] = "\0";
if (row > w->rect.lines - 1 - EDIT_TEXT_VERTICAL_OFFSET - 2 * (edit->fullscreen != 0 ? 0 : 1))
@@ -530,7 +542,7 @@ edit_draw_this_line (WEdit *edit, off_t b, long row, long start_col, long end_co
cur_line = edit->start_line + row;
if (cur_line <= edit->buffer.lines)
- g_snprintf (line_stat, sizeof (line_stat), "%7ld ", cur_line + 1);
+ g_snprintf (line_stat, sizeof (line_stat), "%7ld ", cur_line + 1);
else
{
memset (line_stat, ' ', LINE_STATE_WIDTH);
@@ -539,6 +551,18 @@ edit_draw_this_line (WEdit *edit, off_t b, long row, long start_col, long end_co
if (book_mark_query_color (edit, cur_line, EDITOR_BOOKMARK_COLOR))
g_snprintf (line_stat, 2, "*");
+
+ if (cur_line <= edit->buffer.lines)
+ {
+ struct edit_fold_t *fold;
+
+ fold = edit_fold_find (edit, cur_line);
+ if (fold != NULL && cur_line == fold->line_start)
+ {
+ line_stat[LINE_STATE_WIDTH - 1] = '>';
+ fold_line_count = fold->line_count;
+ }
+ }
}
if (col <= -(edit->start_col + 16))
@@ -603,6 +627,11 @@ edit_draw_this_line (WEdit *edit, off_t b, long row, long start_col, long end_co
else
c = edit_buffer_get_byte (&edit->buffer, q);
+ if (strchr ("{[(", c) != NULL)
+ brace_depth++;
+ else if (strchr ("}])", c) != NULL)
+ brace_depth--;
+
// we don't use bg for mc - fg contains both
if (book_mark != 0)
p->style |= book_mark << 16;
@@ -805,8 +834,48 @@ edit_draw_this_line (WEdit *edit, off_t b, long row, long start_col, long end_co
}
}
+ if (fold_line_count > 0)
+ {
+ char fold_text[64];
+ int fi;
+ int style;
+ line_s *fp;
+
+ style = EDITOR_LINE_STATE_COLOR << 16;
+
+ /* find the opening bracket and determine the closing one */
+ {
+ char close_bracket = '}';
+
+ for (fp = p - 1; fp >= line; fp--)
+ {
+ if (fp->ch == '{' || fp->ch == '[' || fp->ch == '(')
+ {
+ if (fp->ch == '[')
+ close_bracket = ']';
+ else if (fp->ch == '(')
+ close_bracket = ')';
+ fp->style = style;
+ break;
+ }
+ }
+
+ g_snprintf (fold_text, sizeof (fold_text), "...%c", close_bracket);
+ }
+
+ for (fi = 0; fold_text[fi] != '\0' && p < line + MAX_LINE_LEN - 1; fi++)
+ {
+ p->ch = fold_text[fi];
+ p->style = style;
+ p++;
+ }
+ }
+
p->ch = 0;
+ if (brace_depth > 0 && edit_options.line_state && line_stat[LINE_STATE_WIDTH - 1] != '>')
+ line_stat[LINE_STATE_WIDTH - 1] = 'v';
+
print_to_widget (edit, row, start_col, start_col_real, end_col, line, line_stat, book_mark);
}
@@ -838,6 +907,11 @@ render_edit_text (WEdit *edit, long start_row, long start_column, long end_row,
int y1, x1, y2, x2;
int last_line, last_column;
+ /* When folds are active, always do a full page redraw so that
+ line numbers and content are rendered correctly. */
+ if (edit->folds != NULL)
+ force |= REDRAW_PAGE;
+
// draw only visible region
last_line = wh->rect.y + wh->rect.lines - 1;
@@ -891,13 +965,54 @@ render_edit_text (WEdit *edit, long start_row, long start_column, long end_row,
if ((force & REDRAW_PAGE) != 0)
{
+ long current_line;
+
b = edit_buffer_get_forward_offset (&edit->buffer, edit->start_display, start_row, 0);
+ current_line = edit->start_line + start_row;
for (row = start_row; row <= end_row; row++)
{
+ edit_fold_t *fold;
+
if (key_pending (edit))
return;
- edit_draw_this_line (edit, b, row, start_column, end_column);
- b = edit_buffer_get_forward_offset (&edit->buffer, b, 1, 0);
+
+ fold = edit_fold_find (edit, current_line);
+ if (fold != NULL && current_line == fold->line_start)
+ {
+ /* draw fold start line normally (shows '>' indicator) */
+ long saved_start_line = edit->start_line;
+ edit->start_line = current_line - row;
+ edit_draw_this_line (edit, b, row, start_column, end_column);
+ edit->start_line = saved_start_line;
+
+ /* skip hidden lines */
+ b = edit_buffer_get_forward_offset (&edit->buffer, b, fold->line_count + 1, 0);
+ current_line += fold->line_count + 1;
+ }
+ else if (fold != NULL && current_line > fold->line_start)
+ {
+ /* start_line was inside a fold — skip to after it
+ without consuming a screen row */
+ long skip = fold->line_start + fold->line_count + 1 - current_line;
+
+ b = edit_buffer_get_forward_offset (&edit->buffer, b, skip, 0);
+ current_line += skip;
+ row--; /* compensate for loop increment */
+ }
+ else
+ {
+ /* Temporarily adjust start_line so that edit_draw_this_line
+ computes the correct line number as (start_line + row).
+ With folds, the default (start_line + row) is wrong because
+ folded lines don't occupy screen rows. */
+ long saved_start_line = edit->start_line;
+ edit->start_line = current_line - row;
+ edit_draw_this_line (edit, b, row, start_column, end_column);
+ edit->start_line = saved_start_line;
+
+ b = edit_buffer_get_forward_offset (&edit->buffer, b, 1, 0);
+ current_line++;
+ }
}
}
else
diff --git a/src/editor/editmenu.c b/src/editor/editmenu.c
index 5b708f95a1..e38bc05ebf 100644
--- a/src/editor/editmenu.c
+++ b/src/editor/editmenu.c
@@ -162,6 +162,9 @@ create_command_menu (void)
entries = g_list_prepend (entries, menu_separator_new ());
entries = g_list_prepend (entries, menu_entry_new (_ ("Encod&ing..."), CK_SelectCodepage));
entries = g_list_prepend (entries, menu_separator_new ());
+ entries = g_list_prepend (entries, menu_entry_new (_ ("&Fold / Unfold block"), CK_FoldToggle));
+ entries = g_list_prepend (entries, menu_entry_new (_ ("&Unfold all"), CK_UnfoldAll));
+ entries = g_list_prepend (entries, menu_separator_new ());
entries = g_list_prepend (entries, menu_entry_new (_ ("&Refresh screen"), CK_Refresh));
entries = g_list_prepend (entries, menu_separator_new ());
entries = g_list_prepend (
diff --git a/src/editor/editwidget.c b/src/editor/editwidget.c
index 84701c5044..203ff9ebdc 100644
--- a/src/editor/editwidget.c
+++ b/src/editor/editwidget.c
@@ -70,6 +70,8 @@
char *edit_window_state_char = NULL;
char *edit_window_close_char = NULL;
+char *edit_fold_open_char = NULL;
+char *edit_fold_close_char = NULL;
/*** file scope macro definitions ****************************************************************/
@@ -100,6 +102,8 @@ edit_dlg_init (void)
{
edit_window_state_char = mc_skin_get ("widget-editor", "window-state-char", "*");
edit_window_close_char = mc_skin_get ("widget-editor", "window-close-char", "X");
+ edit_fold_open_char = mc_skin_get ("widget-editor", "fold-open-char", "v");
+ edit_fold_close_char = mc_skin_get ("widget-editor", "fold-close-char", ">");
#ifdef HAVE_ASPELL
aspell_init ();
@@ -119,6 +123,8 @@ edit_dlg_deinit (void)
{
g_free (edit_window_state_char);
g_free (edit_window_close_char);
+ g_free (edit_fold_open_char);
+ g_free (edit_fold_close_char);
#ifdef HAVE_ASPELL
aspell_clean ();
@@ -734,6 +740,64 @@ edit_update_cursor (WEdit *edit, const mouse_event_t *event)
else
edit_move_to_prev_col (edit, edit_buffer_get_current_bol (&edit->buffer));
+ /* On a fold start line, handle clicks relative to fold indicator */
+ if (edit->folds != NULL)
+ {
+ edit_fold_t *fold;
+
+ fold = edit_fold_find (edit, edit->buffer.curs_line);
+ if (fold != NULL && edit->buffer.curs_line == fold->line_start)
+ {
+ off_t bol, eol, bracket_off;
+
+ bol = edit_buffer_get_current_bol (&edit->buffer);
+ eol = edit_buffer_get_current_eol (&edit->buffer);
+
+ /* Find the opening bracket by scanning backward from EOL */
+ for (bracket_off = eol - 1; bracket_off >= bol; bracket_off--)
+ {
+ int ch;
+
+ ch = edit_buffer_get_byte (&edit->buffer, bracket_off);
+ if (ch == '{' || ch == '[' || ch == '(')
+ break;
+ }
+
+ if (bracket_off >= bol)
+ {
+ long bracket_col, click_col;
+
+ bracket_col = (long) edit_move_forward3 (edit, bol, 0, bracket_off);
+ click_col = x - edit->start_col - edit_options.line_state_width;
+
+ if (click_col >= bracket_col)
+ {
+ long line_visual_len, fold_visual_end;
+
+ /* Calculate visual end of fold indicator */
+ line_visual_len = (long) edit_move_forward3 (edit, bol, 0, eol);
+ fold_visual_end = line_visual_len + edit_fold_indicator_width (fold);
+
+ /* Snap cursor to the bracket */
+ edit_cursor_move (edit, bracket_off - edit->buffer.curs1);
+ edit->curs_col = bracket_col;
+ edit->prev_col = bracket_col;
+
+ if (edit_options.cursor_beyond_eol && click_col >= fold_visual_end)
+ {
+ /* Click beyond fold indicator — cursor past fold end */
+ edit->over_col = click_col - bracket_col;
+ }
+ else
+ {
+ /* Click on fold indicator — stay at bracket */
+ edit->over_col = 0;
+ }
+ }
+ }
+ }
+ }
+
if (event->msg == MSG_MOUSE_CLICK)
{
edit_mark_cmd (edit, TRUE); // reset
@@ -1128,6 +1192,93 @@ edit_mouse_callback (Widget *w, mouse_msg_t msg, mouse_event_t *event)
}
}
+ // click in the line-state gutter area — toggle fold
+ if (edit_options.line_state)
+ {
+ int gutter_x;
+
+ gutter_x = event->x - (edit->fullscreen != 0 ? 0 : 1);
+ if (gutter_x >= 0 && gutter_x < edit_options.line_state_width)
+ {
+ int click_y;
+ long line;
+ edit_fold_t *fold;
+
+ // map screen row to file line and buffer offset
+ click_y = event->y - (edit->fullscreen != 0 ? 0 : 1);
+ {
+ long target_line;
+ off_t target_b;
+ int r;
+
+ target_line = edit->start_line;
+ target_b = edit->start_display;
+ for (r = 0; r < click_y; r++)
+ {
+ edit_fold_t *f;
+
+ f = edit_fold_find (edit, target_line);
+ if (f != NULL && target_line == f->line_start)
+ {
+ target_b = edit_buffer_get_forward_offset (&edit->buffer, target_b,
+ f->line_count + 1, 0);
+ target_line += f->line_count + 1;
+ }
+ else
+ {
+ target_b =
+ edit_buffer_get_forward_offset (&edit->buffer, target_b, 1, 0);
+ target_line++;
+ }
+ }
+ line = target_line;
+
+ // move cursor directly to the target line
+ edit_cursor_move (edit, target_b - edit->buffer.curs1);
+ }
+
+ fold = edit_fold_find (edit, line);
+
+ if (fold != NULL && line == fold->line_start)
+ {
+ // existing fold — unfold
+ edit_fold_remove (edit, fold->line_start);
+ }
+ else
+ {
+ // try to create fold: find { on this line, match }
+ off_t bol, eol, pos;
+
+ bol = edit_buffer_get_current_bol (&edit->buffer);
+ eol = edit_buffer_get_current_eol (&edit->buffer);
+
+ for (pos = bol; pos < eol; pos++)
+ {
+ if (strchr ("{[(", edit_buffer_get_byte (&edit->buffer, pos)) != NULL)
+ {
+ off_t match;
+
+ edit_cursor_move (edit, pos - edit->buffer.curs1);
+ match = edit_get_bracket (edit, 0, 0);
+ if (match >= 0)
+ {
+ long line2;
+
+ line2 = edit_buffer_count_lines (&edit->buffer, 0, match);
+ if (line2 > line)
+ edit_fold_make (edit, line, line2 - line);
+ }
+ break;
+ }
+ }
+ }
+
+ edit->force |= REDRAW_PAGE;
+ edit_total_update (edit);
+ break;
+ }
+ }
+
MC_FALLTHROUGH; // to start/stop text selection
case MSG_MOUSE_UP:
diff --git a/src/editor/editwidget.h b/src/editor/editwidget.h
index c8569cfb35..ea101d720e 100644
--- a/src/editor/editwidget.h
+++ b/src/editor/editwidget.h
@@ -30,6 +30,15 @@ struct edit_book_mark_t
edit_book_mark_t *prev;
};
+typedef struct edit_fold_t edit_fold_t;
+struct edit_fold_t
+{
+ long line_start; // first line of folded region
+ long line_count; // number of hidden lines below line_start
+ edit_fold_t *next;
+ edit_fold_t *prev;
+};
+
typedef struct edit_syntax_rule_t edit_syntax_rule_t;
struct edit_syntax_rule_t
{
@@ -121,6 +130,9 @@ struct WEdit
edit_book_mark_t *book_mark;
GArray *serialized_bookmarks;
+ // code folding
+ edit_fold_t *folds;
+
// undo stack and pointers
unsigned long undo_stack_pointer;
long *undo_stack;
diff --git a/src/editor/fold.c b/src/editor/fold.c
new file mode 100644
index 0000000000..be5ca6ceb1
--- /dev/null
+++ b/src/editor/fold.c
@@ -0,0 +1,436 @@
+/*
+ Editor code folding
+
+ Copyright (C) 2025
+ Free Software Foundation, Inc.
+
+ This file is part of the Midnight Commander.
+
+ The Midnight Commander is free software: you can redistribute it
+ and/or modify it under the terms of the GNU General Public License as
+ published by the Free Software Foundation, either version 3 of the License,
+ or (at your option) any later version.
+
+ The Midnight Commander is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+ */
+
+/** \file
+ * \brief Source: editor code folding
+ */
+
+#include
+
+#include
+#include
+
+#include "lib/global.h"
+
+#include "edit-impl.h"
+#include "editwidget.h"
+
+/* --------------------------------------------------------------------------------------------- */
+/*** global variables ****************************************************************************/
+/* --------------------------------------------------------------------------------------------- */
+
+/* --------------------------------------------------------------------------------------------- */
+/*** file scope macro definitions ****************************************************************/
+/* --------------------------------------------------------------------------------------------- */
+
+/* --------------------------------------------------------------------------------------------- */
+/*** file scope type declarations ****************************************************************/
+/* --------------------------------------------------------------------------------------------- */
+
+/* --------------------------------------------------------------------------------------------- */
+/*** forward declarations (file scope functions) *************************************************/
+/* --------------------------------------------------------------------------------------------- */
+
+/* --------------------------------------------------------------------------------------------- */
+/*** file scope variables ************************************************************************/
+/* --------------------------------------------------------------------------------------------- */
+
+/* --------------------------------------------------------------------------------------------- */
+/*** file scope functions ************************************************************************/
+/* --------------------------------------------------------------------------------------------- */
+
+/* --------------------------------------------------------------------------------------------- */
+/*** public functions ****************************************************************************/
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * Find the fold that contains the given line.
+ *
+ * @param edit editor object
+ * @param line line number to check
+ * @return pointer to fold if line is the start line or within fold range, NULL otherwise
+ */
+edit_fold_t *
+edit_fold_find (WEdit *edit, long line)
+{
+ edit_fold_t *p;
+
+ if (edit->folds == NULL)
+ return NULL;
+
+ for (p = edit->folds; p != NULL; p = p->next)
+ {
+ if (line >= p->line_start && line <= p->line_start + p->line_count)
+ return p;
+ if (p->line_start > line)
+ break;
+ }
+
+ return NULL;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * Check if a line is hidden inside a fold (not the fold start line itself).
+ *
+ * @param edit editor object
+ * @param line line number to check
+ * @return TRUE if line is hidden
+ */
+gboolean
+edit_fold_is_hidden (WEdit *edit, long line)
+{
+ edit_fold_t *f;
+
+ f = edit_fold_find (edit, line);
+ if (f == NULL)
+ return FALSE;
+
+ return (line > f->line_start && line <= f->line_start + f->line_count);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * Create a new fold region. The first visible line is line_start,
+ * and line_count lines below it become hidden.
+ *
+ * If the new fold overlaps an existing fold, the existing fold is removed first.
+ *
+ * @param edit editor object
+ * @param line_start first line of the fold
+ * @param line_count number of lines to hide
+ */
+void
+edit_fold_make (WEdit *edit, long line_start, long line_count)
+{
+ edit_fold_t *p, *q, *new_fold;
+
+ if (line_count <= 0)
+ return;
+
+ /* remove any folds that overlap with the new region */
+ p = edit->folds;
+ while (p != NULL)
+ {
+ q = p->next;
+ /* overlap: fold [p->line_start, p->line_start + p->line_count]
+ intersects [line_start, line_start + line_count] */
+ if (p->line_start + p->line_count >= line_start && p->line_start <= line_start + line_count)
+ {
+ /* remove p */
+ if (p->prev != NULL)
+ p->prev->next = p->next;
+ else
+ edit->folds = p->next;
+ if (p->next != NULL)
+ p->next->prev = p->prev;
+ g_free (p);
+ }
+ p = q;
+ }
+
+ /* create and insert new fold in sorted order */
+ new_fold = g_new0 (edit_fold_t, 1);
+ new_fold->line_start = line_start;
+ new_fold->line_count = line_count;
+
+ if (edit->folds == NULL || edit->folds->line_start > line_start)
+ {
+ /* insert at head */
+ new_fold->next = edit->folds;
+ new_fold->prev = NULL;
+ if (edit->folds != NULL)
+ edit->folds->prev = new_fold;
+ edit->folds = new_fold;
+ }
+ else
+ {
+ /* find insertion point */
+ for (p = edit->folds; p->next != NULL && p->next->line_start <= line_start; p = p->next)
+ ;
+ new_fold->next = p->next;
+ new_fold->prev = p;
+ if (p->next != NULL)
+ p->next->prev = new_fold;
+ p->next = new_fold;
+ }
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * Remove the fold that contains the given line.
+ *
+ * @param edit editor object
+ * @param line line number
+ * @return TRUE if a fold was removed
+ */
+gboolean
+edit_fold_remove (WEdit *edit, long line)
+{
+ edit_fold_t *f;
+
+ f = edit_fold_find (edit, line);
+ if (f == NULL)
+ return FALSE;
+
+ if (f->prev != NULL)
+ f->prev->next = f->next;
+ else
+ edit->folds = f->next;
+
+ if (f->next != NULL)
+ f->next->prev = f->prev;
+
+ g_free (f);
+ return TRUE;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * Get the next visible line number after the given line.
+ * If the line is a fold start, skip over its hidden lines.
+ *
+ * @param edit editor object
+ * @param line current line number
+ * @return next visible line number
+ */
+long
+edit_fold_next_visible (WEdit *edit, long line)
+{
+ edit_fold_t *f;
+
+ f = edit_fold_find (edit, line);
+ if (f != NULL && line == f->line_start)
+ return f->line_start + f->line_count + 1;
+
+ /* if inside a fold (shouldn't normally happen for cursor), jump past it */
+ if (f != NULL && line > f->line_start)
+ return f->line_start + f->line_count + 1;
+
+ return line + 1;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * Get the previous visible line number before the given line.
+ *
+ * @param edit editor object
+ * @param line current line number
+ * @return previous visible line number
+ */
+long
+edit_fold_prev_visible (WEdit *edit, long line)
+{
+ edit_fold_t *f;
+
+ if (line <= 0)
+ return 0;
+
+ f = edit_fold_find (edit, line - 1);
+ if (f != NULL && (line - 1) > f->line_start)
+ return f->line_start;
+
+ return line - 1;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * Remove all folds.
+ *
+ * @param edit editor object
+ */
+void
+edit_fold_flush (WEdit *edit)
+{
+ edit_fold_t *p, *q;
+
+ for (p = edit->folds; p != NULL; p = q)
+ {
+ q = p->next;
+ g_free (p);
+ }
+ edit->folds = NULL;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * Shift fold line numbers down by 1 for all folds after the given line.
+ * Called when a new line is inserted.
+ *
+ * @param edit editor object
+ * @param line line where insertion happened
+ */
+void
+edit_fold_inc (WEdit *edit, long line)
+{
+ edit_fold_t *p;
+
+ for (p = edit->folds; p != NULL; p = p->next)
+ {
+ if (p->line_start > line)
+ p->line_start++;
+ else if (line > p->line_start && line <= p->line_start + p->line_count)
+ p->line_count++;
+ }
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * Shift fold line numbers up by 1 for all folds after the given line.
+ * Called when a line is deleted.
+ *
+ * @param edit editor object
+ * @param line line where deletion happened
+ */
+void
+edit_fold_dec (WEdit *edit, long line)
+{
+ edit_fold_t *p, *q;
+
+ for (p = edit->folds; p != NULL; p = q)
+ {
+ q = p->next;
+ if (p->line_start > line)
+ p->line_start--;
+ else if (line > p->line_start && line <= p->line_start + p->line_count)
+ {
+ p->line_count--;
+ if (p->line_count <= 0)
+ {
+ /* fold collapsed — remove it */
+ if (p->prev != NULL)
+ p->prev->next = p->next;
+ else
+ edit->folds = p->next;
+ if (p->next != NULL)
+ p->next->prev = p->prev;
+ g_free (p);
+ }
+ }
+ }
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * Calculate the visual width of the fold indicator text "...} (N lines)".
+ *
+ * Uses str_term_width1 for correct i18n/UTF-8 handling.
+ *
+ * @param fold fold structure
+ * @return visual column width of the fold indicator text
+ */
+int
+edit_fold_indicator_width (const struct edit_fold_t *fold)
+{
+ (void) fold;
+ /* fold indicator is "...}" — always 4 columns */
+ return 4;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * Toggle fold at the current cursor line.
+ *
+ * If the cursor is on a fold start, unfold it.
+ * If a selection is active, fold the selected lines.
+ * Otherwise, find an opening bracket on the line, match it, and fold that range.
+ *
+ * @param edit editor object
+ */
+void
+edit_fold_toggle (WEdit *edit)
+{
+ long line;
+ edit_fold_t *fold;
+
+ line = edit->buffer.curs_line;
+ fold = edit_fold_find (edit, line);
+
+ if (fold != NULL && line == fold->line_start)
+ {
+ /* existing fold — unfold */
+ edit_fold_remove (edit, fold->line_start);
+ }
+ else
+ {
+ off_t start_mark, end_mark;
+
+ if (eval_marks (edit, &start_mark, &end_mark))
+ {
+ /* selection active — fold selected lines */
+ long line1, line2;
+
+ line1 = edit_buffer_count_lines (&edit->buffer, 0, start_mark);
+ line2 = edit_buffer_count_lines (&edit->buffer, 0, end_mark);
+ if (line2 > line1)
+ {
+ edit_fold_make (edit, line1, line2 - line1);
+ edit_mark_cmd (edit, TRUE);
+ }
+ }
+ else
+ {
+ /* no selection — find { on this line, match } */
+ off_t bol, eol, pos;
+
+ bol = edit_buffer_get_current_bol (&edit->buffer);
+ eol = edit_buffer_get_current_eol (&edit->buffer);
+
+ for (pos = bol; pos < eol; pos++)
+ {
+ if (strchr ("{[(", edit_buffer_get_byte (&edit->buffer, pos)) != NULL)
+ {
+ off_t match;
+
+ edit_cursor_move (edit, pos - edit->buffer.curs1);
+ match = edit_get_bracket (edit, 0, 0);
+ if (match >= 0)
+ {
+ long line2;
+
+ line2 = edit_buffer_count_lines (&edit->buffer, 0, match);
+ if (line2 > line)
+ {
+ edit_fold_make (edit, line, line2 - line);
+ break;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ edit->mark1 = edit->mark2 = edit->buffer.curs1;
+ edit->force |= REDRAW_PAGE;
+}
+
+/* --------------------------------------------------------------------------------------------- */
diff --git a/src/filemanager/cmd.c b/src/filemanager/cmd.c
index cb2bc49006..e0b7ce8737 100644
--- a/src/filemanager/cmd.c
+++ b/src/filemanager/cmd.c
@@ -33,6 +33,7 @@
#include
#include
+#include
#include
#include
@@ -89,6 +90,8 @@
#include "cd.h"
#include "ioblksize.h" // IO_BUFSIZE
+#include "lib/panel-plugin.h"
+
#include "cmd.h" // Our definitions
/*** global variables ****************************************************************************/
@@ -144,6 +147,36 @@ do_view_cmd (WPanel *panel, gboolean plain_view)
cd_error_message (fe->fname->str);
vfs_path_free (fname_vpath, TRUE);
}
+ else if (panel->is_plugin_panel && panel->plugin != NULL && panel->plugin_data != NULL)
+ {
+ if (panel->plugin->get_local_copy == NULL)
+ {
+ message (D_ERROR, MSG_ERROR, _("This plugin does not support file viewing"));
+ return;
+ }
+
+ {
+ char *local_path = NULL;
+ mc_pp_result_t r;
+
+ r = panel->plugin->get_local_copy (panel->plugin_data, fe->fname->str, &local_path);
+ if (r != MC_PPR_OK || local_path == NULL)
+ {
+ message (D_ERROR, MSG_ERROR, _("Cannot get local copy of %s"), fe->fname->str);
+ return;
+ }
+
+ {
+ vfs_path_t *local_vpath;
+
+ local_vpath = vfs_path_from_str (local_path);
+ view_file (local_vpath, plain_view, use_internal_view);
+ vfs_path_free (local_vpath, TRUE);
+ }
+ unlink (local_path);
+ g_free (local_path);
+ }
+ }
else
{
vfs_path_t *filename_vpath;
@@ -680,6 +713,39 @@ edit_cmd (const WPanel *panel)
if (fe == NULL)
return;
+ if (panel->is_plugin_panel && panel->plugin != NULL && panel->plugin_data != NULL)
+ {
+ if (panel->plugin->get_local_copy == NULL)
+ {
+ message (D_ERROR, MSG_ERROR, _("This plugin does not support file editing"));
+ return;
+ }
+
+ {
+ char *local_path = NULL;
+ mc_pp_result_t r;
+
+ r = panel->plugin->get_local_copy (panel->plugin_data, fe->fname->str, &local_path);
+ if (r != MC_PPR_OK || local_path == NULL)
+ {
+ message (D_ERROR, MSG_ERROR, _("Cannot get local copy of %s"), fe->fname->str);
+ return;
+ }
+
+ {
+ vfs_path_t *local_vpath;
+
+ local_vpath = vfs_path_from_str (local_path);
+ if (regex_command (local_vpath, "Edit") == 0)
+ do_edit (local_vpath);
+ vfs_path_free (local_vpath, TRUE);
+ }
+ unlink (local_path);
+ g_free (local_path);
+ }
+ return;
+ }
+
fname = vfs_path_from_str (fe->fname->str);
if (regex_command (fname, "Edit") == 0)
do_edit (fname);
@@ -699,6 +765,39 @@ edit_cmd_force_internal (const WPanel *panel)
if (fe == NULL)
return;
+ if (panel->is_plugin_panel && panel->plugin != NULL && panel->plugin_data != NULL)
+ {
+ if (panel->plugin->get_local_copy == NULL)
+ {
+ message (D_ERROR, MSG_ERROR, _("This plugin does not support file editing"));
+ return;
+ }
+
+ {
+ char *local_path = NULL;
+ mc_pp_result_t r;
+
+ r = panel->plugin->get_local_copy (panel->plugin_data, fe->fname->str, &local_path);
+ if (r != MC_PPR_OK || local_path == NULL)
+ {
+ message (D_ERROR, MSG_ERROR, _("Cannot get local copy of %s"), fe->fname->str);
+ return;
+ }
+
+ {
+ vfs_path_t *local_vpath;
+
+ local_vpath = vfs_path_from_str (local_path);
+ if (regex_command (local_vpath, "Edit") == 0)
+ edit_file_at_line (local_vpath, TRUE, 1);
+ vfs_path_free (local_vpath, TRUE);
+ }
+ unlink (local_path);
+ g_free (local_path);
+ }
+ return;
+ }
+
fname = vfs_path_from_str (fe->fname->str);
if (regex_command (fname, "Edit") == 0)
edit_file_at_line (fname, TRUE, 1);
@@ -1427,3 +1526,320 @@ encoding_cmd (void)
}
/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * Copy a local file using POSIX read/write, preserving permissions.
+ *
+ * @param src source path (temp file from plugin)
+ * @param dest destination path
+ * @return TRUE on success
+ */
+
+static gboolean
+copy_local_file (const char *src, const char *dest)
+{
+ struct stat st;
+ int fd_src, fd_dest;
+ char buf[IO_BUFSIZE];
+ ssize_t nread;
+ gboolean ok = TRUE;
+
+ fd_src = open (src, O_RDONLY);
+ if (fd_src == -1)
+ return FALSE;
+
+ if (fstat (fd_src, &st) != 0)
+ st.st_mode = S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH;
+
+ fd_dest = open (dest, O_WRONLY | O_CREAT | O_TRUNC, st.st_mode & 0777);
+ if (fd_dest == -1)
+ {
+ close (fd_src);
+ return FALSE;
+ }
+
+ while ((nread = read (fd_src, buf, sizeof (buf))) > 0)
+ {
+ const char *p = buf;
+ ssize_t remaining = nread;
+
+ while (remaining > 0)
+ {
+ ssize_t nw = write (fd_dest, p, remaining);
+ if (nw == -1)
+ {
+ ok = FALSE;
+ goto done;
+ }
+ p += nw;
+ remaining -= nw;
+ }
+ }
+
+ if (nread == -1)
+ ok = FALSE;
+
+ done:
+ close (fd_src);
+ close (fd_dest);
+ return ok;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * Collect names of marked files into a GPtrArray.
+ * If no files are marked, adds the current file name.
+ *
+ * @return newly allocated GPtrArray with g_strdup'd names (caller frees with g_ptr_array_free)
+ */
+
+static GPtrArray *
+plugin_panel_collect_names (WPanel *panel)
+{
+ GPtrArray *names;
+
+ names = g_ptr_array_new_with_free_func (g_free);
+
+ if (panel->marked > 0)
+ {
+ int idx = 0;
+ const GString *fname;
+
+ while ((fname = panel_find_marked_file (panel, &idx)) != NULL)
+ {
+ g_ptr_array_add (names, g_strdup (fname->str));
+ idx++;
+ }
+ }
+ else
+ {
+ const file_entry_t *fe = panel_current_entry (panel);
+
+ if (fe != NULL)
+ g_ptr_array_add (names, g_strdup (fe->fname->str));
+ }
+
+ return names;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+void
+plugin_panel_copy_cmd (WPanel *panel)
+{
+ char *dest_dir;
+ const char *default_dest;
+ GPtrArray *names;
+ guint i;
+
+ if (panel->plugin == NULL || panel->plugin_data == NULL)
+ return;
+
+ if (panel->plugin->get_local_copy == NULL)
+ {
+ message (D_ERROR, MSG_ERROR, _("This plugin does not support copying files"));
+ return;
+ }
+
+ default_dest = vfs_path_as_str (other_panel->cwd_vpath);
+ dest_dir =
+ input_expand_dialog (_("Copy"), _("Copy to:"), MC_HISTORY_FM_PLUGIN_COPY, default_dest,
+ INPUT_COMPLETE_FILENAMES | INPUT_COMPLETE_CD);
+ if (dest_dir == NULL || dest_dir[0] == '\0')
+ {
+ g_free (dest_dir);
+ return;
+ }
+
+ names = plugin_panel_collect_names (panel);
+
+ for (i = 0; i < names->len; i++)
+ {
+ const char *name = (const char *) g_ptr_array_index (names, i);
+ char *local_path = NULL;
+ mc_pp_result_t r;
+ char *dest_path;
+
+ r = panel->plugin->get_local_copy (panel->plugin_data, name, &local_path);
+ if (r != MC_PPR_OK || local_path == NULL)
+ {
+ message (D_ERROR, MSG_ERROR, _("Cannot get local copy of %s"), name);
+ continue;
+ }
+
+ dest_path = mc_build_filename (dest_dir, name, (char *) NULL);
+
+ if (!copy_local_file (local_path, dest_path))
+ message (D_ERROR, MSG_ERROR, _("Cannot copy %s"), name);
+
+ unlink (local_path);
+ g_free (local_path);
+ g_free (dest_path);
+ }
+
+ g_ptr_array_free (names, TRUE);
+ g_free (dest_dir);
+ update_panels (UP_OPTIMIZE, UP_KEEPSEL);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+void
+plugin_panel_delete_cmd (WPanel *panel)
+{
+ GPtrArray *names;
+ mc_pp_result_t r;
+
+ if (panel->plugin == NULL || panel->plugin_data == NULL)
+ return;
+
+ if (panel->plugin->delete_items == NULL)
+ {
+ message (D_ERROR, MSG_ERROR, _("This plugin does not support deleting files"));
+ return;
+ }
+
+ names = plugin_panel_collect_names (panel);
+
+ if (names->len == 0)
+ {
+ g_ptr_array_free (names, TRUE);
+ return;
+ }
+
+ /* Confirmation */
+ if (confirm_delete)
+ {
+ int result;
+
+ if (names->len == 1)
+ result = query_dialog (_("Delete"),
+ _("Delete file from plugin panel?"),
+ D_ERROR, 2, _("&Yes"), _("&No"));
+ else
+ result = query_dialog (_("Delete"),
+ _("Delete tagged files from plugin panel?"),
+ D_ERROR, 2, _("&Yes"), _("&No"));
+
+ if (result != 0)
+ {
+ g_ptr_array_free (names, TRUE);
+ return;
+ }
+ }
+
+ r = panel->plugin->delete_items (panel->plugin_data,
+ (const char **) names->pdata, (int) names->len);
+ if (r != MC_PPR_OK)
+ message (D_ERROR, MSG_ERROR, _("Delete failed"));
+
+ g_ptr_array_free (names, TRUE);
+ update_panels (UP_OPTIMIZE, UP_KEEPSEL);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+void
+plugin_panel_create_cmd (WPanel *panel)
+{
+ mc_pp_result_t r;
+
+ if (panel->plugin == NULL || panel->plugin_data == NULL)
+ return;
+
+ if (panel->plugin->create_item == NULL
+ || (panel->plugin->flags & MC_PPF_CREATE) == 0)
+ {
+ message (D_ERROR, MSG_ERROR, _("This plugin does not support creating items"));
+ return;
+ }
+
+ r = panel->plugin->create_item (panel->plugin_data);
+ if (r == MC_PPR_OK)
+ update_panels (UP_OPTIMIZE, UP_KEEPSEL);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+void
+plugin_panel_move_cmd (WPanel *panel)
+{
+ char *dest_dir;
+ const char *default_dest;
+ GPtrArray *names;
+ GPtrArray *moved_names;
+ guint i;
+
+ if (panel->plugin == NULL || panel->plugin_data == NULL)
+ return;
+
+ if (panel->plugin->get_local_copy == NULL || panel->plugin->delete_items == NULL)
+ {
+ message (D_ERROR, MSG_ERROR, _("This plugin does not support moving files"));
+ return;
+ }
+
+ default_dest = vfs_path_as_str (other_panel->cwd_vpath);
+ dest_dir =
+ input_expand_dialog (_("Move"), _("Move to:"), MC_HISTORY_FM_PLUGIN_MOVE, default_dest,
+ INPUT_COMPLETE_FILENAMES | INPUT_COMPLETE_CD);
+ if (dest_dir == NULL || dest_dir[0] == '\0')
+ {
+ g_free (dest_dir);
+ return;
+ }
+
+ names = plugin_panel_collect_names (panel);
+ moved_names = g_ptr_array_new_with_free_func (g_free);
+
+ for (i = 0; i < names->len; i++)
+ {
+ const char *name = (const char *) g_ptr_array_index (names, i);
+ char *local_path = NULL;
+ mc_pp_result_t r;
+ char *dest_path;
+
+ r = panel->plugin->get_local_copy (panel->plugin_data, name, &local_path);
+ if (r != MC_PPR_OK || local_path == NULL)
+ {
+ message (D_ERROR, MSG_ERROR, _("Cannot get local copy of %s"), name);
+ continue;
+ }
+
+ dest_path = mc_build_filename (dest_dir, name, (char *) NULL);
+
+ if (!copy_local_file (local_path, dest_path))
+ {
+ message (D_ERROR, MSG_ERROR, _("Cannot copy %s"), name);
+ unlink (local_path);
+ g_free (local_path);
+ g_free (dest_path);
+ continue;
+ }
+
+ unlink (local_path);
+ g_free (local_path);
+ g_free (dest_path);
+ g_ptr_array_add (moved_names, g_strdup (name));
+ }
+
+ /* Delete successfully moved files from the plugin */
+ if (moved_names->len > 0)
+ {
+ mc_pp_result_t r;
+
+ r = panel->plugin->delete_items (panel->plugin_data,
+ (const char **) moved_names->pdata,
+ (int) moved_names->len);
+ if (r != MC_PPR_OK)
+ message (D_ERROR, MSG_ERROR, _("Move succeeded but delete from plugin failed"));
+ }
+
+ g_ptr_array_free (names, TRUE);
+ g_ptr_array_free (moved_names, TRUE);
+ g_free (dest_dir);
+ update_panels (UP_OPTIMIZE, UP_KEEPSEL);
+}
+
+/* --------------------------------------------------------------------------------------------- */
diff --git a/src/filemanager/cmd.h b/src/filemanager/cmd.h
index 93e95c3d26..4a5dfb7555 100644
--- a/src/filemanager/cmd.h
+++ b/src/filemanager/cmd.h
@@ -94,6 +94,12 @@ const char *chattr_get_as_str (unsigned long attr);
/* find.c */
void find_cmd (WPanel *panel);
+/* plugin panel file operations */
+void plugin_panel_copy_cmd (WPanel *panel);
+void plugin_panel_move_cmd (WPanel *panel);
+void plugin_panel_delete_cmd (WPanel *panel);
+void plugin_panel_create_cmd (WPanel *panel);
+
/* --------------------------------------------------------------------------------------------- */
/*** inline functions ****************************************************************************/
/* --------------------------------------------------------------------------------------------- */
diff --git a/src/filemanager/filemanager.c b/src/filemanager/filemanager.c
index 3b3a810f40..c56baf015e 100644
--- a/src/filemanager/filemanager.c
+++ b/src/filemanager/filemanager.c
@@ -63,6 +63,7 @@
#include "lib/keybind.h"
#include "lib/event.h"
+#include "lib/panel-plugin.h"
#include "tree.h"
#include "boxes.h" // sort_box(), tree_box()
@@ -196,6 +197,7 @@ create_panel_menu (void)
entries = g_list_prepend (entries, menu_entry_new (_ ("&Info"), CK_PanelInfo));
entries = g_list_prepend (entries, menu_entry_new (_ ("&Tree"), CK_PanelTree));
entries = g_list_prepend (entries, menu_entry_new (_ ("Paneli&ze"), CK_Panelize));
+ entries = g_list_prepend (entries, menu_entry_new (_ ("Pl&ugin panel..."), CK_PanelPlugin));
entries = g_list_prepend (entries, menu_separator_new ());
entries =
g_list_prepend (entries, menu_entry_new (_ ("&Listing format..."), CK_SetupListingFormat));
@@ -875,6 +877,8 @@ create_file_manager (void)
group_add_widget (g, the_menubar);
init_menu ();
+ mc_panel_plugins_load ();
+
create_panels ();
group_add_widget (g, get_panel_widget (0));
group_add_widget (g, get_panel_widget (1));
@@ -1097,6 +1101,45 @@ midnight_execute_cmd (Widget *sender, long command)
// stop quick search before executing any command
send_message (current_panel, NULL, MSG_ACTION, CK_SearchStop, NULL);
+ /* Block destructive/inapplicable file operations on plugin panels — the listed
+ entries are virtual and do not exist on the real filesystem.
+ View and Edit are allowed (the viewer/editor will handle missing files). */
+ if (current_panel->is_plugin_panel)
+ {
+ switch (command)
+ {
+ case CK_Copy:
+ plugin_panel_copy_cmd (current_panel);
+ return MSG_HANDLED;
+ case CK_Move:
+ plugin_panel_move_cmd (current_panel);
+ return MSG_HANDLED;
+ case CK_Delete:
+ plugin_panel_delete_cmd (current_panel);
+ return MSG_HANDLED;
+ case CK_ChangeMode:
+ case CK_ChangeOwn:
+ case CK_ChangeOwnAdvanced:
+#ifdef ENABLE_EXT2FS_ATTR
+ case CK_ChangeAttributes:
+#endif
+ case CK_Link:
+ case CK_LinkSymbolic:
+ case CK_LinkSymbolicRelative:
+ case CK_LinkSymbolicEdit:
+ case CK_DirSize:
+ case CK_CompareDirs:
+#ifdef USE_DIFF_VIEW
+ case CK_CompareFiles:
+#endif
+ message (D_ERROR, MSG_ERROR,
+ _ ("This operation is not supported for plugin panels"));
+ return MSG_HANDLED;
+ default:
+ break;
+ }
+ }
+
switch (command)
{
case CK_About:
@@ -1226,6 +1269,9 @@ midnight_execute_cmd (Widget *sender, long command)
case CK_Panelize:
panel_panelize_restore ();
break;
+ case CK_PanelPlugin:
+ panel_plugin_select_and_activate (current_panel);
+ break;
case CK_Help:
help_cmd ();
break;
@@ -1564,6 +1610,7 @@ midnight_callback (Widget *w, Widget *sender, widget_msg_t msg, int parm, void *
return midnight_execute_cmd (sender, parm);
case MSG_DESTROY:
+ mc_panel_plugins_shutdown ();
panel_deinit ();
return MSG_HANDLED;
diff --git a/src/filemanager/layout.c b/src/filemanager/layout.c
index ff2c9fdad8..af56c58b8e 100644
--- a/src/filemanager/layout.c
+++ b/src/filemanager/layout.c
@@ -1278,6 +1278,10 @@ swap_panels (void)
panelswap (current);
panelswap (is_panelized);
panelswap (panelized_descr);
+ panelswap (is_plugin_panel);
+ panelswap (plugin);
+ panelswap (plugin_data);
+ panelswap (plugin_host);
panelswap (dir_stat);
#undef panelswap
diff --git a/src/filemanager/panel.c b/src/filemanager/panel.c
index af14fa0f6c..d62535232a 100644
--- a/src/filemanager/panel.c
+++ b/src/filemanager/panel.c
@@ -52,6 +52,7 @@
#include "lib/widget.h"
#include "lib/charsets.h" // get_codepage_id ()
#include "lib/event.h"
+#include "lib/panel-plugin.h"
#include "src/setup.h" // For loading/saving panel options
#include "src/execute.h"
@@ -160,6 +161,8 @@ static const char *string_marked (const file_entry_t *fe, int len);
static const char *string_space (const file_entry_t *fe, int len);
static const char *string_dot (const file_entry_t *fe, int len);
+static void panel_plugin_reload (WPanel *panel);
+
/*** file scope variables ************************************************************************/
static panel_field_t panel_fields[] = {
@@ -271,6 +274,43 @@ panelized_descr_free (panelized_descr_t *p)
/* --------------------------------------------------------------------------------------------- */
+static char *
+plugin_title_last_component (const char *title)
+{
+ char *tmp, *base, *ret;
+ size_t len;
+
+ if (title == NULL || title[0] == '\0')
+ return NULL;
+
+ tmp = g_strdup (title);
+ len = strlen (tmp);
+
+ while (len > 1 && tmp[len - 1] == '/')
+ {
+ tmp[len - 1] = '\0';
+ len--;
+ }
+
+ base = strrchr (tmp, '/');
+ if (base != NULL)
+ base++;
+ else
+ base = tmp;
+
+ if (base[0] == '\0')
+ {
+ g_free (tmp);
+ return NULL;
+ }
+
+ ret = g_strdup (base);
+ g_free (tmp);
+ return ret;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
static void
set_colors (const WPanel *panel)
{
@@ -1258,7 +1298,31 @@ show_dir (const WPanel *panel)
tty_setcolor (CORE_NORMAL_COLOR);
widget_gotoyx (w, 0, 3);
- if (panel->is_panelized)
+ if (panel->is_plugin_panel && panel->plugin != NULL)
+ {
+ const char *title = NULL;
+
+ if (panel->plugin->get_title != NULL && panel->plugin_data != NULL)
+ title = panel->plugin->get_title (panel->plugin_data);
+
+ if (panel->plugin->proto != NULL)
+ {
+ /* format as "Proto:/path" */
+ if (title != NULL)
+ tty_printf (" %s:%s ", panel->plugin->proto, title);
+ else
+ tty_printf (" %s:/ ", panel->plugin->proto);
+ }
+ else
+ {
+ if (title == NULL)
+ title = panel->plugin->display_name;
+ if (title == NULL)
+ title = panel->plugin->name;
+ tty_printf (" %s ", title);
+ }
+ }
+ else if (panel->is_panelized)
tty_printf (" %s ", _ ("Panelize"));
else
{
@@ -1274,9 +1338,12 @@ show_dir (const WPanel *panel)
if (panel->active)
tty_setcolor (CORE_REVERSE_COLOR);
- tmp = panel_correct_path_to_show (panel);
- tty_printf (" %s ", str_term_trim (tmp, MIN (MAX (w->rect.cols - 12, 0), w->rect.cols)));
- g_free (tmp);
+ if (!panel->is_plugin_panel)
+ {
+ tmp = panel_correct_path_to_show (panel);
+ tty_printf (" %s ", str_term_trim (tmp, MIN (MAX (w->rect.cols - 12, 0), w->rect.cols)));
+ g_free (tmp);
+ }
if (!panels_options.show_mini_info)
{
@@ -2270,6 +2337,47 @@ prev_page (WPanel *panel)
static void
goto_parent_dir (WPanel *panel)
{
+ if (panel->is_plugin_panel && panel->plugin != NULL && panel->plugin_data != NULL)
+ {
+ char *focus_name = NULL;
+
+ if (panel->plugin->get_title != NULL)
+ {
+ const char *title = panel->plugin->get_title (panel->plugin_data);
+ focus_name = plugin_title_last_component (title);
+ }
+
+ if (panel->plugin->chdir != NULL && (panel->plugin->flags & MC_PPF_NAVIGATE) != 0)
+ {
+ mc_pp_result_t r;
+
+ r = panel->plugin->chdir (panel->plugin_data, "..");
+ if (r == MC_PPR_OK)
+ {
+ panel_plugin_reload (panel);
+ if (focus_name != NULL)
+ panel_set_current_by_name (panel, focus_name);
+ g_free (focus_name);
+ return;
+ }
+ if (r == MC_PPR_NOT_SUPPORTED)
+ {
+ g_free (focus_name);
+ panel_plugin_close (panel);
+ return;
+ }
+ }
+ else
+ {
+ g_free (focus_name);
+ // no navigation support — close plugin panel
+ panel_plugin_close (panel);
+ return;
+ }
+
+ g_free (focus_name);
+ }
+
if (!panel->is_panelized)
cd_up_dir (panel);
else
@@ -2939,10 +3047,65 @@ static inline gboolean
do_enter (WPanel *panel)
{
const file_entry_t *fe;
+ char *focus_name = NULL;
fe = panel_current_entry (panel);
+ if (fe == NULL)
+ return FALSE;
+
+ if (panel->is_plugin_panel && panel->plugin != NULL && panel->plugin_data != NULL)
+ {
+ if (panel->plugin->get_title != NULL && fe->fname != NULL && fe->fname->str != NULL
+ && strcmp (fe->fname->str, "..") == 0)
+ {
+ const char *title = panel->plugin->get_title (panel->plugin_data);
+ focus_name = plugin_title_last_component (title);
+ }
+
+ // let plugin handle enter first
+ if (panel->plugin->enter != NULL)
+ {
+ mc_pp_result_t r;
+
+ r = panel->plugin->enter (panel->plugin_data, fe->fname->str, &fe->st);
+ if (r == MC_PPR_OK)
+ {
+ panel_plugin_reload (panel);
+ if (focus_name != NULL)
+ panel_set_current_by_name (panel, focus_name);
+ g_free (focus_name);
+ return TRUE;
+ }
+ }
- return (fe == NULL ? FALSE : do_enter_on_file_entry (panel, fe));
+ // try chdir for directories (st_mode == 0 covers ".." from dir_list_init)
+ if ((S_ISDIR (fe->st.st_mode) || link_isdir (fe) || fe->st.st_mode == 0)
+ && panel->plugin->chdir != NULL && (panel->plugin->flags & MC_PPF_NAVIGATE) != 0)
+ {
+ mc_pp_result_t r;
+
+ r = panel->plugin->chdir (panel->plugin_data, fe->fname->str);
+ if (r == MC_PPR_OK)
+ {
+ panel_plugin_reload (panel);
+ if (focus_name != NULL)
+ panel_set_current_by_name (panel, focus_name);
+ g_free (focus_name);
+ return TRUE;
+ }
+ if (r == MC_PPR_NOT_SUPPORTED)
+ {
+ g_free (focus_name);
+ panel_plugin_close (panel);
+ return TRUE;
+ }
+ }
+
+ g_free (focus_name);
+ return TRUE; // consume the key even if plugin didn't handle it
+ }
+
+ return do_enter_on_file_entry (panel, fe);
}
/* --------------------------------------------------------------------------------------------- */
@@ -3579,7 +3742,10 @@ panel_execute_cmd (WPanel *panel, long command)
view_raw_cmd (panel);
break;
case CK_EditNew:
- edit_cmd_new ();
+ if (panel->is_plugin_panel)
+ plugin_panel_create_cmd (panel);
+ else
+ edit_cmd_new ();
break;
case CK_MoveSingle:
rename_cmd_local (panel);
@@ -3724,6 +3890,96 @@ panel_execute_cmd (WPanel *panel, long command)
return res;
}
+/* --------------------------------------------------------------------------------------------- */
+/* Panel plugin host callbacks and support */
+/* --------------------------------------------------------------------------------------------- */
+
+static void
+host_refresh_impl (mc_panel_host_t *host)
+{
+ WPanel *panel = (WPanel *) host->host_data;
+ panel->dirty = TRUE;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static void
+host_set_hint_impl (mc_panel_host_t *host, const char *text)
+{
+ (void) host;
+ set_hintbar (text);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static void
+host_message_impl (mc_panel_host_t *host, int flags, const char *title, const char *text)
+{
+ (void) host;
+ message (flags, title, "%s", text);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static void
+host_close_plugin_impl (mc_panel_host_t *host, const char *dir_path)
+{
+ WPanel *panel = (WPanel *) host->host_data;
+
+ (void) dir_path;
+ panel_plugin_close (panel);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static int
+host_get_marked_count_impl (mc_panel_host_t *host)
+{
+ WPanel *panel = (WPanel *) host->host_data;
+ return panel->marked;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static const GString *
+host_get_next_marked_impl (mc_panel_host_t *host, int *current)
+{
+ WPanel *panel = (WPanel *) host->host_data;
+ return panel_get_marked_file (panel, current);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static void
+panel_plugin_reload (WPanel *panel)
+{
+ gboolean was_dotdot = FALSE;
+
+ if (!panel->is_plugin_panel || panel->plugin == NULL || panel->plugin_data == NULL)
+ return;
+
+ {
+ const file_entry_t *fe = panel_current_entry (panel);
+ if (fe != NULL && fe->fname != NULL && fe->fname->str != NULL && strcmp (fe->fname->str, "..") == 0)
+ was_dotdot = TRUE;
+ }
+
+ panel_clean_dir (panel);
+ // panel_clean_dir resets is_panelized; restore plugin state
+ panel->is_panelized = TRUE;
+ panel->is_plugin_panel = TRUE;
+
+ dir_list_init (&panel->dir);
+ panel->plugin->get_items (panel->plugin_data, &panel->dir);
+
+ panel_re_sort (panel);
+
+ if (was_dotdot && panel->dir.len > 1)
+ panel_set_current (panel, 1);
+
+ panel->dirty = TRUE;
+}
+
/* --------------------------------------------------------------------------------------------- */
static cb_ret_t
@@ -3823,12 +4079,25 @@ panel_callback (Widget *w, Widget *sender, widget_msg_t msg, int parm, void *dat
return MSG_HANDLED;
case MSG_KEY:
+ // let plugin try to handle the key first
+ if (panel->is_plugin_panel && panel->plugin != NULL && panel->plugin_data != NULL
+ && panel->plugin->handle_key != NULL)
+ {
+ if (panel->plugin->handle_key (panel->plugin_data, parm) == MC_PPR_OK)
+ {
+ panel_plugin_reload (panel);
+ return MSG_HANDLED;
+ }
+ }
return panel_key (panel, parm);
case MSG_ACTION:
return panel_execute_cmd (panel, parm);
case MSG_DESTROY:
+ // close plugin panel if active
+ if (panel->is_plugin_panel)
+ panel_plugin_close (panel);
vfs_stamp_path (panel->cwd_vpath);
// unsubscribe from "history_load" event
mc_event_del (h->event_group, MCEVENT_HISTORY_LOAD, panel_load_history, w);
@@ -4176,6 +4445,26 @@ update_one_panel_widget (WPanel *panel, panel_update_flags_t flags, const char *
gboolean free_pointer;
char *my_current_file = NULL;
+ if (panel->is_plugin_panel)
+ {
+ const file_entry_t *fe;
+ char *saved_name = NULL;
+
+ if (current_file == UP_KEEPSEL)
+ {
+ fe = panel_current_entry (panel);
+ if (fe != NULL)
+ saved_name = g_strndup (fe->fname->str, fe->fname->len);
+ current_file = saved_name;
+ }
+
+ panel_plugin_reload (panel);
+ panel_set_current_by_name (panel, current_file);
+ panel->dirty = TRUE;
+ g_free (saved_name);
+ return;
+ }
+
if ((flags & UP_RELOAD) != 0)
{
panel->is_panelized = FALSE;
@@ -5387,6 +5676,9 @@ panel_panelize_save (WPanel *panel)
dir_list *list = &panel->dir;
dir_list *plist;
+ if (panel->is_plugin_panel)
+ return;
+
panel_panelize_change_root (panel, panel->cwd_vpath);
plist = &panel->panelized_descr->list;
@@ -5462,7 +5754,7 @@ panel_cd (WPanel *panel, const vfs_path_t *new_dir_vpath, enum cd_enum exact)
gboolean res;
const vfs_path_t *_new_dir_vpath = new_dir_vpath;
- if (panel->is_panelized)
+ if (panel->is_panelized && panel->panelized_descr != NULL)
{
size_t new_vpath_len;
@@ -5488,3 +5780,130 @@ panel_cd (WPanel *panel, const vfs_path_t *new_dir_vpath, enum cd_enum exact)
}
/* --------------------------------------------------------------------------------------------- */
+
+void
+panel_plugin_activate (WPanel *panel, const mc_panel_plugin_t *plugin, const char *open_path)
+{
+ mc_panel_host_t *host;
+
+ if (panel == NULL || plugin == NULL)
+ return;
+
+ // close any previous plugin
+ if (panel->is_plugin_panel)
+ panel_plugin_close (panel);
+
+ // build host interface
+ host = g_new0 (mc_panel_host_t, 1);
+ host->refresh = host_refresh_impl;
+ host->set_hint = host_set_hint_impl;
+ host->message = host_message_impl;
+ host->close_plugin = host_close_plugin_impl;
+ host->get_marked_count = host_get_marked_count_impl;
+ host->get_next_marked = host_get_next_marked_impl;
+ host->host_data = panel;
+
+ panel->plugin_data = plugin->open (host, open_path);
+ if (panel->plugin_data == NULL)
+ {
+ g_free (host);
+ return;
+ }
+
+ panel->plugin = plugin;
+ panel->plugin_host = host;
+ panel->is_plugin_panel = TRUE;
+ panel->is_panelized = TRUE;
+
+ // populate dir list
+ panel_clean_dir (panel);
+ // panel_clean_dir resets flags — restore them
+ panel->is_panelized = TRUE;
+ panel->is_plugin_panel = TRUE;
+
+ dir_list_init (&panel->dir);
+ plugin->get_items (panel->plugin_data, &panel->dir);
+
+ panel_re_sort (panel);
+ panel->dirty = TRUE;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+void
+panel_plugin_close (WPanel *panel)
+{
+ if (panel == NULL || !panel->is_plugin_panel)
+ return;
+
+ if (panel->plugin != NULL && panel->plugin_data != NULL)
+ panel->plugin->close (panel->plugin_data);
+
+ panel->plugin = NULL;
+ panel->plugin_data = NULL;
+ panel->is_plugin_panel = FALSE;
+
+ g_free (panel->plugin_host);
+ panel->plugin_host = NULL;
+
+ panel->is_panelized = FALSE;
+ panel_reload (panel);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+void
+panel_plugin_select_and_activate (WPanel *panel)
+{
+ const GSList *plugins;
+ const GSList *iter;
+ Listbox *listbox;
+ int count = 0;
+ int result;
+
+ plugins = mc_panel_plugin_list ();
+ if (plugins == NULL)
+ {
+ message (D_ERROR, _ ("Plugin panel"), "%s", _ ("No panel plugins are loaded."));
+ return;
+ }
+
+ listbox = listbox_window_new (12, 40, _ ("Plugin panel"), "[Panel Plugins]");
+
+ for (iter = plugins; iter != NULL; iter = g_slist_next (iter))
+ {
+ const mc_panel_plugin_t *p = (const mc_panel_plugin_t *) iter->data;
+ const char *label = p->display_name != NULL ? p->display_name : p->name;
+
+ listbox_add_item (listbox->list, LISTBOX_APPEND_AT_END, 0, label, (void *) p, FALSE);
+ count++;
+ }
+
+ if (count == 0)
+ {
+ // shouldn't happen since we checked above, but be safe
+ widget_destroy (WIDGET (listbox->dlg));
+ return;
+ }
+
+ result = listbox_run (listbox);
+ if (result >= 0)
+ {
+ const mc_panel_plugin_t *selected = NULL;
+ int i = 0;
+
+ for (iter = plugins; iter != NULL; iter = g_slist_next (iter), i++)
+ {
+ if (i == result)
+ {
+ selected = (const mc_panel_plugin_t *) iter->data;
+ break;
+ }
+ }
+
+ if (selected != NULL)
+ panel_plugin_activate (panel, selected, NULL);
+ }
+}
+
+/* --------------------------------------------------------------------------------------------- */
diff --git a/src/filemanager/panel.h b/src/filemanager/panel.h
index 05673b69e2..f5a81e5164 100644
--- a/src/filemanager/panel.h
+++ b/src/filemanager/panel.h
@@ -14,6 +14,7 @@
#include "lib/widget.h" // Widget
#include "lib/filehighlight.h"
#include "lib/file-entry.h"
+#include "lib/panel-plugin.h"
#include "dir.h" // dir_list
@@ -91,6 +92,11 @@ typedef struct
gboolean is_panelized; // Panelization: special mode, can't reload the file list
panelized_descr_t *panelized_descr; // Panelization descriptor
+ gboolean is_plugin_panel; // TRUE when driven by a panel plugin
+ const mc_panel_plugin_t *plugin; // active plugin descriptor, or NULL
+ void *plugin_data; // instance handle from plugin->open()
+ mc_panel_host_t *plugin_host; // host interface given to the plugin
+
int codepage; // Panel codepage
dir_list dir; // Directory contents
@@ -200,6 +206,11 @@ void panel_panelize_save (WPanel *panel);
void panel_init (void);
void panel_deinit (void);
+void panel_plugin_activate (WPanel *panel, const mc_panel_plugin_t *plugin,
+ const char *open_path);
+void panel_plugin_close (WPanel *panel);
+void panel_plugin_select_and_activate (WPanel *panel);
+
/* --------------------------------------------------------------------------------------------- */
/*** inline functions ****************************************************************************/
/* --------------------------------------------------------------------------------------------- */
diff --git a/src/history.h b/src/history.h
index 23b647222f..2ea8fc22ab 100644
--- a/src/history.h
+++ b/src/history.h
@@ -25,6 +25,8 @@
#define MC_HISTORY_FM_TREE_COPY "mc.fm.tree-copy"
#define MC_HISTORY_FM_TREE_MOVE "mc.fm.tree-move"
#define MC_HISTORY_FM_PANELIZE_ADD "mc.fm.panelize.add"
+#define MC_HISTORY_FM_PLUGIN_COPY "mc.fm.plugin-copy"
+#define MC_HISTORY_FM_PLUGIN_MOVE "mc.fm.plugin-move"
#define MC_HISTORY_FM_FILTERED_VIEW "mc.fm.filtered-view"
#define MC_HISTORY_FM_PANEL_SELECT ":select_cmd: Select "
#define MC_HISTORY_FM_PANEL_UNSELECT ":select_cmd: Unselect "
diff --git a/src/keymap.c b/src/keymap.c
index 892aa4c7a8..b323c6690a 100644
--- a/src/keymap.c
+++ b/src/keymap.c
@@ -509,6 +509,8 @@ static const global_keymap_ini_t default_editor_keymap[] = {
{ "Sort", "alt-t" },
{ "Mail", "alt-m" },
{ "ExternalCommand", "alt-u" },
+ { "FoldToggle", "alt-shift-f" },
+ { "UnfoldAll", "alt-shift-u" },
#ifdef HAVE_ASPELL
{ "SpellCheckCurrentWord", "ctrl-p" },
#endif
diff --git a/src/learn.c b/src/learn.c
index 3380de3a64..0a66128179 100644
--- a/src/learn.c
+++ b/src/learn.c
@@ -63,6 +63,7 @@ typedef struct
Widget *label;
gboolean ok;
char *sequence;
+ int modifiers;
} learnkey_t;
/*** forward declarations (file scope functions) *************************************************/
@@ -76,6 +77,55 @@ static learnkey_t *learnkeys = NULL;
static int learn_total;
static int learnok;
static gboolean learnchanged = FALSE;
+static unsigned long learn_mod_ctrl_id = 0;
+static unsigned long learn_mod_meta_id = 0;
+static unsigned long learn_mod_shift_id = 0;
+
+/* --------------------------------------------------------------------------------------------- */
+
+static int
+learn_selected_modifiers (const WDialog *dlg)
+{
+ int modifiers = 0;
+ Widget *w;
+
+ if (dlg == NULL)
+ return 0;
+
+ w = widget_find_by_id (WIDGET (dlg), learn_mod_ctrl_id);
+ if (w != NULL && CHECK (w)->state)
+ modifiers |= KEY_M_CTRL;
+
+ w = widget_find_by_id (WIDGET (dlg), learn_mod_meta_id);
+ if (w != NULL && CHECK (w)->state)
+ modifiers |= KEY_M_ALT;
+
+ w = widget_find_by_id (WIDGET (dlg), learn_mod_shift_id);
+ if (w != NULL && CHECK (w)->state)
+ modifiers |= KEY_M_SHIFT;
+
+ return modifiers;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static char *
+learn_build_key_name (const char *base_name, int modifiers)
+{
+ GString *name;
+
+ name = g_string_sized_new (32);
+
+ if ((modifiers & KEY_M_CTRL) != 0)
+ g_string_append (name, "ctrl-");
+ if ((modifiers & KEY_M_ALT) != 0)
+ g_string_append (name, "meta-");
+ if ((modifiers & KEY_M_SHIFT) != 0)
+ g_string_append (name, "shift-");
+
+ g_string_append (name, base_name);
+ return g_string_free (name, FALSE);
+}
/* --------------------------------------------------------------------------------------------- */
/*** file scope functions ************************************************************************/
@@ -86,6 +136,8 @@ learn_button (WButton *button, int action)
{
WDialog *d;
char *seq;
+ int modifiers;
+ int keycode;
(void) button;
@@ -109,13 +161,17 @@ learn_button (WButton *button, int action)
*/
gboolean seq_ok = FALSE;
+ modifiers = learn_selected_modifiers (DIALOG (WIDGET (button)->owner));
+ keycode = key_name_conv_tab[action - B_USER].code | modifiers;
+
if (strcmp (seq, "\\e") != 0 && strcmp (seq, "\\e\\e") != 0 && strcmp (seq, "^m") != 0
&& strcmp (seq, "^i") != 0 && (seq[1] != '\0' || *seq < ' ' || *seq > '~'))
{
learnchanged = TRUE;
learnkeys[action - B_USER].sequence = seq;
+ learnkeys[action - B_USER].modifiers = modifiers;
seq = convert_controls (seq);
- seq_ok = define_sequence (key_name_conv_tab[action - B_USER].code, seq, MCKEY_NOACTION);
+ seq_ok = define_sequence (keycode, seq, MCKEY_NOACTION);
}
if (!seq_ok)
@@ -176,7 +232,7 @@ learn_check_key (int c)
for (i = 0; i < learn_total; i++)
{
- if (key_name_conv_tab[i].code != c || learnkeys[i].ok)
+ if (((key_name_conv_tab[i].code | learnkeys[i].modifiers) != c) || learnkeys[i].ok)
continue;
widget_select (learnkeys[i].button);
@@ -253,7 +309,7 @@ init_learn (void)
WGroup *g;
const int dlg_width = 78;
- const int dlg_height = 23;
+ const int dlg_height = 25;
// buttons
WButton *bt0, *bt1;
@@ -262,6 +318,9 @@ init_learn (void)
int x, y, i;
const key_code_name_t *key;
+ WCheck *mod_ctrl;
+ WCheck *mod_meta;
+ WCheck *mod_shift;
#ifdef ENABLE_NLS
static gboolean i18n_flag = FALSE;
@@ -300,6 +359,7 @@ init_learn (void)
learnkeys[i].ok = FALSE;
learnkeys[i].sequence = NULL;
+ learnkeys[i].modifiers = 0;
label = _ (key_name_conv_tab[i].longname);
padding = 16 - str_term_width1 (label);
@@ -320,13 +380,26 @@ init_learn (void)
}
}
+ group_add_widget (g, hline_new (dlg_height - 10, -1, -1));
+ group_add_widget (g, label_new (dlg_height - 9, 3, _ ("With modifiers")));
+
+ mod_ctrl = check_new (dlg_height - 9, 22, FALSE, _ ("C&trl"));
+ mod_meta = check_new (dlg_height - 9, 34, FALSE, _ ("&Meta (Alt)"));
+ mod_shift = check_new (dlg_height - 9, 54, FALSE, _ ("&Shift"));
+ learn_mod_ctrl_id = WIDGET (mod_ctrl)->id;
+ learn_mod_meta_id = WIDGET (mod_meta)->id;
+ learn_mod_shift_id = WIDGET (mod_shift)->id;
+ group_add_widget (g, mod_ctrl);
+ group_add_widget (g, mod_meta);
+ group_add_widget (g, mod_shift);
+
group_add_widget (g, hline_new (dlg_height - 8, -1, -1));
group_add_widget (
g,
label_new (dlg_height - 7, 5,
_ ("Press all the keys mentioned here. After you have done it, check\n"
- "which keys are not marked with OK. Press space on the missing\n"
- "key, or click with the mouse to define it. Move around with Tab.")));
+ "which keys are not marked with OK. Press space on the missing key\n"
+ "or click with the mouse to define it. Move around with Tab.")));
group_add_widget (g, hline_new (dlg_height - 4, -1, -1));
// buttons
bt0 = button_new (dlg_height - 3, 1, B_ENTER, DEFPUSH_BUTTON, b0, NULL);
@@ -369,10 +442,12 @@ learn_save (void)
if (learnkeys[i].sequence != NULL)
{
char *esc_str;
+ char *key_name;
esc_str = str_escape (learnkeys[i].sequence, -1, ";\\", TRUE);
- mc_config_set_string_raw_value (mc_global.main_config, section,
- key_name_conv_tab[i].name, esc_str);
+ key_name = learn_build_key_name (key_name_conv_tab[i].name, learnkeys[i].modifiers);
+ mc_config_set_string_raw_value (mc_global.main_config, section, key_name, esc_str);
+ g_free (key_name);
g_free (esc_str);
profile_changed = TRUE;
diff --git a/src/main.c b/src/main.c
index ce12c3cbee..b961c5e0ef 100644
--- a/src/main.c
+++ b/src/main.c
@@ -304,6 +304,7 @@ main (int argc, char *argv[])
vfs_expire (TRUE);
(void) my_rmdir (tmpdir);
+ vfs_plugins_done ();
vfs_shut ();
done_setup ();
g_free (saved_other_dir);
@@ -445,6 +446,7 @@ main (int argc, char *argv[])
(void) my_rmdir (tmpdir);
// Virtual File System shutdown
+ vfs_plugins_done ();
vfs_shut ();
flush_extension_file (); // does only free memory
diff --git a/src/panel-plugins/Makefile.am b/src/panel-plugins/Makefile.am
new file mode 100644
index 0000000000..d635f0b467
--- /dev/null
+++ b/src/panel-plugins/Makefile.am
@@ -0,0 +1,9 @@
+SUBDIRS = systemd
+
+if ENABLE_VFS_SFTP
+SUBDIRS += sftp
+endif
+
+SUBDIRS += docker
+
+DIST_SUBDIRS = systemd sftp docker
diff --git a/src/panel-plugins/docker/Makefile.am b/src/panel-plugins/docker/Makefile.am
new file mode 100644
index 0000000000..57dfcfec3f
--- /dev/null
+++ b/src/panel-plugins/docker/Makefile.am
@@ -0,0 +1,7 @@
+panelplugindir = $(panel_plugins_dir)
+panelplugin_LTLIBRARIES = mc-panel-docker.la
+
+mc_panel_docker_la_SOURCES = docker.c
+mc_panel_docker_la_CPPFLAGS = $(GLIB_CFLAGS) -I$(top_srcdir)
+mc_panel_docker_la_LDFLAGS = -module -avoid-version
+mc_panel_docker_la_LIBADD = $(GLIB_LIBS)
diff --git a/src/panel-plugins/docker/docker.c b/src/panel-plugins/docker/docker.c
new file mode 100644
index 0000000000..e41d6d1cd8
--- /dev/null
+++ b/src/panel-plugins/docker/docker.c
@@ -0,0 +1,1250 @@
+/*
+ Docker resources panel plugin.
+
+ Copyright (C) 2026
+ Free Software Foundation, Inc.
+
+ This file is part of the Midnight Commander.
+
+ The Midnight Commander is free software: you can redistribute it
+ and/or modify it under the terms of the GNU General Public License as
+ published by the Free Software Foundation, either version 3 of the License,
+ or (at your option) any later version.
+
+ The Midnight Commander is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+ */
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+#include "lib/global.h"
+#include "lib/panel-plugin.h"
+#include "lib/widget.h"
+
+#include "src/filemanager/dir.h"
+
+/*** file scope type declarations ****************************************************************/
+
+typedef enum
+{
+ DOCKER_VIEW_ROOT = 0,
+ DOCKER_VIEW_CONTAINERS_PROJECTS,
+ DOCKER_VIEW_CONTAINERS_ITEMS,
+ DOCKER_VIEW_CONTAINER_DETAILS,
+ DOCKER_VIEW_IMAGES,
+ DOCKER_VIEW_VOLUMES,
+ DOCKER_VIEW_NETWORKS
+} docker_view_t;
+
+typedef struct
+{
+ char *name; /* display name */
+ char *id; /* real object id/name used by docker CLI */
+ gboolean is_dir;
+ off_t size;
+} docker_item_t;
+
+typedef struct
+{
+ mc_panel_host_t *host;
+
+ docker_view_t view;
+ GPtrArray *items; /* docker_item_t* for current view */
+
+ char *root_focus;
+ char *current_project;
+ char *current_container_id;
+ char *current_container_name;
+
+ char *title_buf;
+} docker_data_t;
+
+/*** forward declarations (file scope functions) *************************************************/
+
+static void *docker_open (mc_panel_host_t *host, const char *open_path);
+static void docker_close (void *plugin_data);
+static mc_pp_result_t docker_get_items (void *plugin_data, void *list_ptr);
+static mc_pp_result_t docker_chdir (void *plugin_data, const char *path);
+static mc_pp_result_t docker_enter (void *plugin_data, const char *name, const struct stat *st);
+static mc_pp_result_t docker_get_local_copy (void *plugin_data, const char *fname, char **local_path);
+static mc_pp_result_t docker_delete_items (void *plugin_data, const char **names, int count);
+static const char *docker_get_title (void *plugin_data);
+static mc_pp_result_t docker_create_item (void *plugin_data);
+
+/*** file scope variables ************************************************************************/
+
+static const char docker_daemon_info_file[] = "daemon-info.txt";
+static const char docker_version_file[] = "version.txt";
+static const char docker_inspect_file[] = "inspect.json";
+static const char docker_ungrouped_project[] = "ungrouped";
+
+static const mc_panel_plugin_t docker_plugin = {
+ .api_version = MC_PANEL_PLUGIN_API_VERSION,
+ .name = "docker",
+ .display_name = "Docker",
+ .proto = "docker",
+ .prefix = NULL,
+ .flags =
+ MC_PPF_NAVIGATE | MC_PPF_GET_FILES | MC_PPF_DELETE | MC_PPF_CUSTOM_TITLE | MC_PPF_CREATE,
+
+ .open = docker_open,
+ .close = docker_close,
+ .get_items = docker_get_items,
+
+ .chdir = docker_chdir,
+ .enter = docker_enter,
+ .get_local_copy = docker_get_local_copy,
+ .delete_items = docker_delete_items,
+ .get_title = docker_get_title,
+ .handle_key = NULL,
+ .create_item = docker_create_item,
+};
+
+/*** file scope functions ************************************************************************/
+
+static void
+add_entry (dir_list *list, const char *name, mode_t mode, off_t size)
+{
+ struct stat st;
+
+ memset (&st, 0, sizeof (st));
+ st.st_mode = mode;
+ st.st_size = size;
+ st.st_mtime = time (NULL);
+ st.st_uid = getuid ();
+ st.st_gid = getgid ();
+ st.st_nlink = 1;
+
+ dir_list_append (list, name, &st, S_ISDIR (mode), FALSE);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static void
+docker_item_free (gpointer p)
+{
+ docker_item_t *item = (docker_item_t *) p;
+
+ g_free (item->name);
+ g_free (item->id);
+ g_free (item);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static off_t
+parse_size_to_bytes (const char *text)
+{
+ char *endptr = NULL;
+ double value;
+ double mult = 1.0;
+ const char *unit;
+
+ if (text == NULL || *text == '\0')
+ return 0;
+
+ value = g_ascii_strtod (text, &endptr);
+ if (endptr == text)
+ return 0;
+
+ while (*endptr == ' ')
+ endptr++;
+ unit = endptr;
+
+ if (g_ascii_strcasecmp (unit, "B") == 0 || *unit == '\0')
+ mult = 1.0;
+ else if (g_ascii_strcasecmp (unit, "KB") == 0)
+ mult = 1000.0;
+ else if (g_ascii_strcasecmp (unit, "MB") == 0)
+ mult = 1000.0 * 1000.0;
+ else if (g_ascii_strcasecmp (unit, "GB") == 0)
+ mult = 1000.0 * 1000.0 * 1000.0;
+ else if (g_ascii_strcasecmp (unit, "TB") == 0)
+ mult = 1000.0 * 1000.0 * 1000.0 * 1000.0;
+ else if (g_ascii_strcasecmp (unit, "KiB") == 0)
+ mult = 1024.0;
+ else if (g_ascii_strcasecmp (unit, "MiB") == 0)
+ mult = 1024.0 * 1024.0;
+ else if (g_ascii_strcasecmp (unit, "GiB") == 0)
+ mult = 1024.0 * 1024.0 * 1024.0;
+ else if (g_ascii_strcasecmp (unit, "TiB") == 0)
+ mult = 1024.0 * 1024.0 * 1024.0 * 1024.0;
+
+ return (off_t) (value * mult);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static void
+clear_items (docker_data_t *data)
+{
+ if (data->items != NULL)
+ {
+ g_ptr_array_free (data->items, TRUE);
+ data->items = NULL;
+ }
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static const docker_item_t *
+find_item_by_name (const docker_data_t *data, const char *name)
+{
+ guint i;
+
+ if (data->items == NULL)
+ return NULL;
+
+ for (i = 0; i < data->items->len; i++)
+ {
+ const docker_item_t *item = (const docker_item_t *) g_ptr_array_index (data->items, i);
+
+ if (strcmp (item->name, name) == 0)
+ return item;
+ }
+
+ return NULL;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static docker_view_t
+view_from_root_path (const char *path)
+{
+ if (strcmp (path, "containers") == 0)
+ return DOCKER_VIEW_CONTAINERS_PROJECTS;
+ if (strcmp (path, "images") == 0)
+ return DOCKER_VIEW_IMAGES;
+ if (strcmp (path, "volumes") == 0)
+ return DOCKER_VIEW_VOLUMES;
+ if (strcmp (path, "networks") == 0)
+ return DOCKER_VIEW_NETWORKS;
+
+ return DOCKER_VIEW_ROOT;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static gboolean
+run_cmd (const char *cmd, char **output, char **err_text)
+{
+ gchar *std_out = NULL;
+ gchar *std_err = NULL;
+ gint status = 0;
+ GError *error = NULL;
+ gboolean spawned;
+ gboolean exited_ok;
+
+ spawned = g_spawn_command_line_sync (cmd, &std_out, &std_err, &status, &error);
+ if (!spawned)
+ {
+ if (err_text != NULL)
+ {
+ if (error != NULL && error->message != NULL)
+ *err_text = g_strdup (error->message);
+ else
+ *err_text = g_strdup (_ ("Failed to start docker command"));
+ }
+
+ if (output != NULL)
+ *output = NULL;
+
+ if (error != NULL)
+ g_error_free (error);
+ g_free (std_out);
+ g_free (std_err);
+ return FALSE;
+ }
+
+#if GLIB_CHECK_VERSION(2, 70, 0)
+ exited_ok = g_spawn_check_wait_status (status, NULL);
+#else
+ exited_ok = g_spawn_check_exit_status (status, NULL);
+#endif
+
+ if (output != NULL)
+ *output = std_out;
+ else
+ g_free (std_out);
+
+ if (err_text != NULL)
+ *err_text = std_err;
+ else
+ g_free (std_err);
+
+ return exited_ok;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static gboolean
+is_ungrouped_project (const char *project)
+{
+ return (project == NULL || project[0] == '\0' || strcmp (project, docker_ungrouped_project) == 0);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static gboolean
+project_match (const char *selected_project, const char *project_from_docker)
+{
+ if (is_ungrouped_project (selected_project))
+ return is_ungrouped_project (project_from_docker);
+
+ return (project_from_docker != NULL && strcmp (selected_project, project_from_docker) == 0);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static gboolean
+docker_load_containers_output (char **output, char **err_text)
+{
+ return run_cmd (
+ "docker ps -a --format '{{.ID}}\\t{{.Names}}\\t{{.Image}}\\t{{.Status}}\\t{{.Label \"com.docker.compose.project\"}}'",
+ output, err_text);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static GPtrArray *
+parse_projects_from_containers (const char *output, const char *focused_project)
+{
+ GPtrArray *items;
+ GHashTable *seen;
+ char **lines;
+ int i;
+
+ items = g_ptr_array_new_with_free_func (docker_item_free);
+ seen = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, NULL);
+
+ if (output == NULL)
+ goto done;
+
+ lines = g_strsplit (output, "\n", -1);
+
+ for (i = 0; lines[i] != NULL; i++)
+ {
+ char **parts;
+ int part_count = 0;
+ const char *project;
+ char *project_key;
+
+ if (lines[i][0] == '\0')
+ continue;
+
+ parts = g_strsplit (lines[i], "\t", -1);
+ while (parts[part_count] != NULL)
+ part_count++;
+
+ if (part_count < 5)
+ {
+ g_strfreev (parts);
+ continue;
+ }
+
+ project = parts[4];
+ project_key = is_ungrouped_project (project) ? g_strdup (docker_ungrouped_project)
+ : g_strdup (project);
+
+ if (!g_hash_table_contains (seen, project_key))
+ {
+ docker_item_t *item = g_new0 (docker_item_t, 1);
+
+ item->id = g_strdup (project_key);
+ item->name = g_strdup (project_key);
+ item->is_dir = TRUE;
+ item->size = 0;
+
+ if (focused_project != NULL && strcmp (item->id, focused_project) == 0)
+ g_ptr_array_insert (items, 0, item);
+ else
+ g_ptr_array_add (items, item);
+ g_hash_table_add (seen, project_key);
+ }
+ else
+ g_free (project_key);
+
+ g_strfreev (parts);
+ }
+
+ g_strfreev (lines);
+
+done:
+ if (!g_hash_table_contains (seen, docker_ungrouped_project))
+ {
+ docker_item_t *item = g_new0 (docker_item_t, 1);
+
+ item->id = g_strdup (docker_ungrouped_project);
+ item->name = g_strdup (docker_ungrouped_project);
+ item->is_dir = TRUE;
+ item->size = 0;
+
+ g_ptr_array_add (items, item);
+ }
+
+ g_hash_table_destroy (seen);
+ return items;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static GPtrArray *
+parse_container_items_from_project (const char *output, const char *project_name,
+ const char *focused_container_id)
+{
+ GPtrArray *items;
+ char **lines;
+ int i;
+
+ items = g_ptr_array_new_with_free_func (docker_item_free);
+
+ if (output == NULL)
+ return items;
+
+ lines = g_strsplit (output, "\n", -1);
+
+ for (i = 0; lines[i] != NULL; i++)
+ {
+ char **parts;
+ int part_count = 0;
+ const char *project;
+
+ if (lines[i][0] == '\0')
+ continue;
+
+ parts = g_strsplit (lines[i], "\t", -1);
+ while (parts[part_count] != NULL)
+ part_count++;
+
+ if (part_count < 5)
+ {
+ g_strfreev (parts);
+ continue;
+ }
+
+ project = parts[4];
+ if (!project_match (project_name, project))
+ {
+ g_strfreev (parts);
+ continue;
+ }
+
+ {
+ docker_item_t *item = g_new0 (docker_item_t, 1);
+
+ item->id = g_strdup (parts[0]);
+ item->name = g_strdup_printf ("%s (%s) %s", parts[1], parts[2], parts[3]);
+ item->is_dir = TRUE;
+ item->size = 0;
+
+ if (focused_container_id != NULL && strcmp (item->id, focused_container_id) == 0)
+ g_ptr_array_insert (items, 0, item);
+ else
+ g_ptr_array_add (items, item);
+ }
+
+ g_strfreev (parts);
+ }
+
+ g_strfreev (lines);
+ return items;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static GPtrArray *
+parse_generic_list_output (docker_view_t view, const char *output)
+{
+ GPtrArray *items;
+ char **lines;
+ int i;
+
+ items = g_ptr_array_new_with_free_func (docker_item_free);
+ if (output == NULL)
+ return items;
+
+ lines = g_strsplit (output, "\n", -1);
+
+ for (i = 0; lines[i] != NULL; i++)
+ {
+ docker_item_t *item;
+ char **parts;
+ int part_count = 0;
+
+ if (lines[i][0] == '\0')
+ continue;
+
+ parts = g_strsplit (lines[i], "\t", -1);
+ while (parts[part_count] != NULL)
+ part_count++;
+
+ if (part_count < 2)
+ {
+ g_strfreev (parts);
+ continue;
+ }
+
+ item = g_new0 (docker_item_t, 1);
+ item->is_dir = FALSE;
+ item->size = 0;
+ item->id = g_strdup (parts[0]);
+
+ switch (view)
+ {
+ case DOCKER_VIEW_IMAGES:
+ item->name = g_strdup (parts[1]);
+ if (part_count >= 3)
+ item->size = parse_size_to_bytes (parts[2]);
+ break;
+ case DOCKER_VIEW_VOLUMES:
+ if (part_count >= 3)
+ item->name = g_strdup_printf ("%s (%s)", parts[1], parts[2]);
+ else
+ item->name = g_strdup (parts[1]);
+ break;
+ case DOCKER_VIEW_NETWORKS:
+ if (part_count >= 4)
+ item->name = g_strdup_printf ("%s (%s/%s)", parts[1], parts[2], parts[3]);
+ else
+ item->name = g_strdup (parts[1]);
+ break;
+ default:
+ item->name = g_strdup (parts[1]);
+ break;
+ }
+
+ g_ptr_array_add (items, item);
+ g_strfreev (parts);
+ }
+
+ g_strfreev (lines);
+ return items;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static gboolean
+reload_items (docker_data_t *data)
+{
+ char *output = NULL;
+ char *err_text = NULL;
+
+ clear_items (data);
+
+ switch (data->view)
+ {
+ case DOCKER_VIEW_ROOT:
+ return TRUE;
+
+ case DOCKER_VIEW_CONTAINERS_PROJECTS:
+ if (!docker_load_containers_output (&output, &err_text))
+ goto cmd_failed;
+
+ data->items = parse_projects_from_containers (output, data->current_project);
+ break;
+
+ case DOCKER_VIEW_CONTAINERS_ITEMS:
+ if (!docker_load_containers_output (&output, &err_text))
+ goto cmd_failed;
+
+ data->items =
+ parse_container_items_from_project (output, data->current_project, data->current_container_id);
+ break;
+
+ case DOCKER_VIEW_CONTAINER_DETAILS:
+ {
+ docker_item_t *item;
+
+ data->items = g_ptr_array_new_with_free_func (docker_item_free);
+
+ item = g_new0 (docker_item_t, 1);
+ item->id = g_strdup (docker_inspect_file);
+ item->name = g_strdup (docker_inspect_file);
+ item->is_dir = FALSE;
+ item->size = 0;
+ g_ptr_array_add (data->items, item);
+
+ break;
+ }
+
+ case DOCKER_VIEW_IMAGES:
+ if (!run_cmd ("docker images --format '{{.ID}}\\t{{.Repository}}:{{.Tag}}\\t{{.Size}}'", &output,
+ &err_text))
+ goto cmd_failed;
+
+ data->items = parse_generic_list_output (data->view, output);
+ break;
+
+ case DOCKER_VIEW_VOLUMES:
+ if (!run_cmd ("docker volume ls --format '{{.Name}}\\t{{.Name}}\\t{{.Driver}}'", &output,
+ &err_text))
+ goto cmd_failed;
+
+ data->items = parse_generic_list_output (data->view, output);
+ break;
+
+ case DOCKER_VIEW_NETWORKS:
+ if (!run_cmd ("docker network ls --format '{{.ID}}\\t{{.Name}}\\t{{.Driver}}\\t{{.Scope}}'",
+ &output, &err_text))
+ goto cmd_failed;
+
+ data->items = parse_generic_list_output (data->view, output);
+ break;
+
+ default:
+ data->items = g_ptr_array_new_with_free_func (docker_item_free);
+ break;
+ }
+
+ g_free (output);
+ g_free (err_text);
+
+ if (data->items == NULL)
+ data->items = g_ptr_array_new_with_free_func (docker_item_free);
+
+ return TRUE;
+
+cmd_failed:
+ if (err_text != NULL && err_text[0] != '\0')
+ message (D_ERROR, MSG_ERROR, "%s", err_text);
+
+ g_free (output);
+ g_free (err_text);
+
+ data->items = g_ptr_array_new_with_free_func (docker_item_free);
+ return FALSE;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static gboolean
+write_temp_content (const char *prefix, const char *content, char **local_path)
+{
+ GError *error = NULL;
+ int fd;
+
+ fd = g_file_open_tmp (prefix, local_path, &error);
+ if (fd == -1)
+ {
+ if (error != NULL)
+ g_error_free (error);
+ return FALSE;
+ }
+
+ if (content == NULL)
+ content = "";
+
+ if (write (fd, content, strlen (content)) == -1)
+ {
+ close (fd);
+ unlink (*local_path);
+ g_free (*local_path);
+ *local_path = NULL;
+ return FALSE;
+ }
+
+ close (fd);
+ return TRUE;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static gboolean
+show_create_container_dialog (char **image, char **name, char **command, gboolean *detach)
+{
+ /* clang-format off */
+ quick_widget_t quick_widgets[] = {
+ QUICK_LABELED_INPUT (N_("Image:"), input_label_above,
+ *image != NULL ? *image : "", "docker-create-image",
+ image, NULL, FALSE, FALSE, INPUT_COMPLETE_NONE),
+ QUICK_SEPARATOR (FALSE),
+ QUICK_LABELED_INPUT (N_("Container name:"), input_label_above,
+ *name != NULL ? *name : "", "docker-create-name",
+ name, NULL, FALSE, FALSE, INPUT_COMPLETE_NONE),
+ QUICK_SEPARATOR (FALSE),
+ QUICK_LABELED_INPUT (N_("Command:"), input_label_above,
+ *command != NULL ? *command : "", "docker-create-cmd",
+ command, NULL, FALSE, FALSE, INPUT_COMPLETE_NONE),
+ QUICK_SEPARATOR (FALSE),
+ QUICK_CHECKBOX (N_("Run in &detached mode (-d)"), detach, NULL),
+ QUICK_BUTTONS_OK_CANCEL,
+ QUICK_END,
+ };
+ /* clang-format on */
+
+ WRect r = { -1, -1, 0, 56 };
+
+ quick_dialog_t qdlg = {
+ .rect = r,
+ .title = N_ ("Create Docker Container"),
+ .help = "[Docker Plugin]",
+ .widgets = quick_widgets,
+ .callback = NULL,
+ .mouse_callback = NULL,
+ };
+
+ return (quick_dialog (&qdlg) == B_ENTER);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static void
+set_view (docker_data_t *data, docker_view_t new_view)
+{
+ data->view = new_view;
+ clear_items (data);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+/* Plugin callbacks */
+/* --------------------------------------------------------------------------------------------- */
+
+static void *
+docker_open (mc_panel_host_t *host, const char *open_path)
+{
+ docker_data_t *data;
+
+ (void) open_path;
+
+ data = g_new0 (docker_data_t, 1);
+ data->host = host;
+ data->view = DOCKER_VIEW_ROOT;
+ data->items = NULL;
+ data->root_focus = NULL;
+ data->current_project = NULL;
+ data->current_container_id = NULL;
+ data->current_container_name = NULL;
+ data->title_buf = NULL;
+
+ return data;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static void
+docker_close (void *plugin_data)
+{
+ docker_data_t *data = (docker_data_t *) plugin_data;
+
+ clear_items (data);
+ g_free (data->root_focus);
+ g_free (data->current_project);
+ g_free (data->current_container_id);
+ g_free (data->current_container_name);
+ g_free (data->title_buf);
+ g_free (data);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static mc_pp_result_t
+docker_get_items (void *plugin_data, void *list_ptr)
+{
+ dir_list *list = (dir_list *) list_ptr;
+ docker_data_t *data = (docker_data_t *) plugin_data;
+ guint idx;
+
+ if (data->view == DOCKER_VIEW_ROOT)
+ {
+ const char *sections[] = { "containers", "images", "volumes", "networks" };
+ int sec_i;
+
+ for (sec_i = 0; sec_i < 4; sec_i++)
+ if (data->root_focus != NULL && strcmp (sections[sec_i], data->root_focus) == 0)
+ add_entry (list, sections[sec_i], S_IFDIR | 0755, 0);
+
+ for (sec_i = 0; sec_i < 4; sec_i++)
+ if (data->root_focus == NULL || strcmp (sections[sec_i], data->root_focus) != 0)
+ add_entry (list, sections[sec_i], S_IFDIR | 0755, 0);
+
+ add_entry (list, docker_daemon_info_file, S_IFREG | 0644, 0);
+ add_entry (list, docker_version_file, S_IFREG | 0644, 0);
+ return MC_PPR_OK;
+ }
+
+ if (data->items == NULL)
+ reload_items (data);
+
+ if (data->items != NULL)
+ {
+ for (idx = 0; idx < data->items->len; idx++)
+ {
+ const docker_item_t *item = (const docker_item_t *) g_ptr_array_index (data->items, idx);
+ mode_t mode = item->is_dir ? (S_IFDIR | 0755) : (S_IFREG | 0644);
+
+ add_entry (list, item->name, mode, item->size);
+ }
+ }
+
+ return MC_PPR_OK;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static mc_pp_result_t
+docker_chdir (void *plugin_data, const char *path)
+{
+ docker_data_t *data = (docker_data_t *) plugin_data;
+
+ if (strcmp (path, "..") == 0)
+ {
+ switch (data->view)
+ {
+ case DOCKER_VIEW_ROOT:
+ return MC_PPR_NOT_SUPPORTED;
+
+ case DOCKER_VIEW_CONTAINERS_PROJECTS:
+ g_free (data->root_focus);
+ data->root_focus = g_strdup ("containers");
+ set_view (data, DOCKER_VIEW_ROOT);
+ return MC_PPR_OK;
+
+ case DOCKER_VIEW_IMAGES:
+ g_free (data->root_focus);
+ data->root_focus = g_strdup ("images");
+ set_view (data, DOCKER_VIEW_ROOT);
+ return MC_PPR_OK;
+
+ case DOCKER_VIEW_VOLUMES:
+ g_free (data->root_focus);
+ data->root_focus = g_strdup ("volumes");
+ set_view (data, DOCKER_VIEW_ROOT);
+ return MC_PPR_OK;
+
+ case DOCKER_VIEW_NETWORKS:
+ g_free (data->root_focus);
+ data->root_focus = g_strdup ("networks");
+ set_view (data, DOCKER_VIEW_ROOT);
+ return MC_PPR_OK;
+
+ case DOCKER_VIEW_CONTAINERS_ITEMS:
+ set_view (data, DOCKER_VIEW_CONTAINERS_PROJECTS);
+ reload_items (data);
+ return MC_PPR_OK;
+
+ case DOCKER_VIEW_CONTAINER_DETAILS:
+ set_view (data, DOCKER_VIEW_CONTAINERS_ITEMS);
+ reload_items (data);
+ return MC_PPR_OK;
+
+ default:
+ set_view (data, DOCKER_VIEW_ROOT);
+ return MC_PPR_OK;
+ }
+ }
+
+ if (data->view == DOCKER_VIEW_ROOT)
+ {
+ docker_view_t next = view_from_root_path (path);
+
+ if (next == DOCKER_VIEW_ROOT)
+ return MC_PPR_FAILED;
+
+ g_free (data->root_focus);
+ data->root_focus = g_strdup (path);
+ set_view (data, next);
+ reload_items (data);
+ return MC_PPR_OK;
+ }
+
+ if (data->view == DOCKER_VIEW_CONTAINERS_PROJECTS)
+ {
+ const docker_item_t *project = find_item_by_name (data, path);
+
+ if (project == NULL)
+ return MC_PPR_FAILED;
+
+ g_free (data->current_project);
+ data->current_project = g_strdup (project->id);
+ g_free (data->current_container_id);
+ data->current_container_id = NULL;
+ g_free (data->current_container_name);
+ data->current_container_name = NULL;
+ set_view (data, DOCKER_VIEW_CONTAINERS_ITEMS);
+ reload_items (data);
+ return MC_PPR_OK;
+ }
+
+ if (data->view == DOCKER_VIEW_CONTAINERS_ITEMS)
+ {
+ const docker_item_t *container = find_item_by_name (data, path);
+
+ if (container == NULL)
+ return MC_PPR_FAILED;
+
+ g_free (data->current_container_id);
+ data->current_container_id = g_strdup (container->id);
+
+ g_free (data->current_container_name);
+ data->current_container_name = g_strdup (container->name);
+
+ set_view (data, DOCKER_VIEW_CONTAINER_DETAILS);
+ reload_items (data);
+ return MC_PPR_OK;
+ }
+
+ return MC_PPR_FAILED;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static mc_pp_result_t
+docker_enter (void *plugin_data, const char *name, const struct stat *st)
+{
+ docker_data_t *data = (docker_data_t *) plugin_data;
+
+ (void) st;
+ if (data->view == DOCKER_VIEW_ROOT)
+ {
+ docker_view_t next = view_from_root_path (name);
+
+ if (next != DOCKER_VIEW_ROOT)
+ {
+ g_free (data->root_focus);
+ data->root_focus = g_strdup (name);
+ set_view (data, next);
+ reload_items (data);
+ return MC_PPR_OK;
+ }
+
+ if (strcmp (name, docker_daemon_info_file) == 0 || strcmp (name, docker_version_file) == 0)
+ return MC_PPR_NOT_SUPPORTED;
+
+ return MC_PPR_FAILED;
+ }
+
+ if (data->view == DOCKER_VIEW_CONTAINERS_PROJECTS || data->view == DOCKER_VIEW_CONTAINERS_ITEMS)
+ return docker_chdir (plugin_data, name);
+
+ if (data->view == DOCKER_VIEW_CONTAINER_DETAILS)
+ {
+ if (strcmp (name, docker_inspect_file) == 0)
+ return MC_PPR_NOT_SUPPORTED;
+
+ return MC_PPR_FAILED;
+ }
+
+ if (find_item_by_name (data, name) != NULL)
+ return MC_PPR_NOT_SUPPORTED;
+
+ return MC_PPR_FAILED;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static mc_pp_result_t
+docker_get_local_copy (void *plugin_data, const char *fname, char **local_path)
+{
+ docker_data_t *data = (docker_data_t *) plugin_data;
+ char *output = NULL;
+ char *err_text = NULL;
+ const char *cmd = NULL;
+ char *cmd_dynamic = NULL;
+ gboolean ok;
+
+ if (data->view == DOCKER_VIEW_ROOT)
+ {
+ if (strcmp (fname, docker_daemon_info_file) == 0)
+ cmd = "docker info";
+ else if (strcmp (fname, docker_version_file) == 0)
+ cmd = "docker version";
+ else
+ return MC_PPR_FAILED;
+ }
+ else if (data->view == DOCKER_VIEW_CONTAINER_DETAILS)
+ {
+ char *quoted_id;
+
+ if (data->current_container_id == NULL || strcmp (fname, docker_inspect_file) != 0)
+ return MC_PPR_FAILED;
+
+ quoted_id = g_shell_quote (data->current_container_id);
+ cmd_dynamic = g_strdup_printf ("docker inspect %s", quoted_id);
+ g_free (quoted_id);
+ cmd = cmd_dynamic;
+ }
+ else if (data->view == DOCKER_VIEW_CONTAINERS_ITEMS)
+ {
+ const docker_item_t *container = find_item_by_name (data, fname);
+ char *quoted_id;
+
+ if (container == NULL)
+ return MC_PPR_FAILED;
+
+ quoted_id = g_shell_quote (container->id);
+ cmd_dynamic = g_strdup_printf ("docker inspect %s", quoted_id);
+ g_free (quoted_id);
+ cmd = cmd_dynamic;
+ }
+ else
+ {
+ const docker_item_t *item = find_item_by_name (data, fname);
+ char *quoted_id;
+
+ if (item == NULL)
+ return MC_PPR_FAILED;
+
+ quoted_id = g_shell_quote (item->id);
+ cmd_dynamic = g_strdup_printf ("docker inspect %s", quoted_id);
+ g_free (quoted_id);
+ cmd = cmd_dynamic;
+ }
+
+ ok = run_cmd (cmd, &output, &err_text);
+ g_free (cmd_dynamic);
+
+ if (!ok)
+ {
+ if (err_text != NULL && err_text[0] != '\0')
+ message (D_ERROR, MSG_ERROR, "%s", err_text);
+ g_free (output);
+ g_free (err_text);
+ return MC_PPR_FAILED;
+ }
+
+ ok = write_temp_content ("mc-pp-docker-XXXXXX", output, local_path);
+
+ g_free (output);
+ g_free (err_text);
+
+ return ok ? MC_PPR_OK : MC_PPR_FAILED;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static mc_pp_result_t
+docker_delete_items (void *plugin_data, const char **names, int count)
+{
+ docker_data_t *data = (docker_data_t *) plugin_data;
+ int i;
+
+ if (data->view == DOCKER_VIEW_ROOT || data->view == DOCKER_VIEW_CONTAINERS_PROJECTS
+ || data->view == DOCKER_VIEW_CONTAINER_DETAILS)
+ return MC_PPR_NOT_SUPPORTED;
+
+ for (i = 0; i < count; i++)
+ {
+ const docker_item_t *item;
+ char *quoted;
+ char *cmd;
+ char *output = NULL;
+ char *err_text = NULL;
+
+ item = find_item_by_name (data, names[i]);
+ if (item == NULL)
+ continue;
+
+ quoted = g_shell_quote (item->id);
+
+ switch (data->view)
+ {
+ case DOCKER_VIEW_CONTAINERS_ITEMS:
+ cmd = g_strdup_printf ("docker rm %s", quoted);
+ break;
+ case DOCKER_VIEW_IMAGES:
+ cmd = g_strdup_printf ("docker rmi %s", quoted);
+ break;
+ case DOCKER_VIEW_VOLUMES:
+ cmd = g_strdup_printf ("docker volume rm %s", quoted);
+ break;
+ case DOCKER_VIEW_NETWORKS:
+ cmd = g_strdup_printf ("docker network rm %s", quoted);
+ break;
+ default:
+ g_free (quoted);
+ return MC_PPR_NOT_SUPPORTED;
+ }
+
+ if (!run_cmd (cmd, &output, &err_text) && err_text != NULL && err_text[0] != '\0')
+ message (D_ERROR, MSG_ERROR, "%s", err_text);
+
+ g_free (output);
+ g_free (err_text);
+ g_free (cmd);
+ g_free (quoted);
+ }
+
+ reload_items (data);
+ return MC_PPR_OK;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static const char *
+docker_get_title (void *plugin_data)
+{
+ docker_data_t *data = (docker_data_t *) plugin_data;
+
+ g_free (data->title_buf);
+
+ switch (data->view)
+ {
+ case DOCKER_VIEW_ROOT:
+ data->title_buf = g_strdup ("/");
+ break;
+
+ case DOCKER_VIEW_CONTAINERS_PROJECTS:
+ data->title_buf = g_strdup ("/containers");
+ break;
+
+ case DOCKER_VIEW_CONTAINERS_ITEMS:
+ data->title_buf = g_strdup_printf ("/containers/%s",
+ data->current_project != NULL ? data->current_project
+ : docker_ungrouped_project);
+ break;
+
+ case DOCKER_VIEW_CONTAINER_DETAILS:
+ data->title_buf = g_strdup_printf ("/containers/%s/%s",
+ data->current_project != NULL ? data->current_project
+ : docker_ungrouped_project,
+ data->current_container_name != NULL
+ ? data->current_container_name
+ : "container");
+ break;
+
+ case DOCKER_VIEW_IMAGES:
+ data->title_buf = g_strdup ("/images");
+ break;
+
+ case DOCKER_VIEW_VOLUMES:
+ data->title_buf = g_strdup ("/volumes");
+ break;
+
+ case DOCKER_VIEW_NETWORKS:
+ data->title_buf = g_strdup ("/networks");
+ break;
+
+ default:
+ data->title_buf = g_strdup ("/");
+ break;
+ }
+
+ return data->title_buf;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static mc_pp_result_t
+docker_create_item (void *plugin_data)
+{
+ docker_data_t *data = (docker_data_t *) plugin_data;
+ char *image = NULL;
+ char *name = NULL;
+ char *command = NULL;
+ gboolean detach = TRUE;
+ char *q_image = NULL;
+ char *q_name = NULL;
+ char *q_cmd = NULL;
+ char *q_project = NULL;
+ char *cmd = NULL;
+ char *output = NULL;
+ char *err_text = NULL;
+ mc_pp_result_t result = MC_PPR_FAILED;
+
+ if (data->view != DOCKER_VIEW_CONTAINERS_ITEMS && data->view != DOCKER_VIEW_CONTAINERS_PROJECTS)
+ return MC_PPR_NOT_SUPPORTED;
+
+ if (!show_create_container_dialog (&image, &name, &command, &detach))
+ goto done;
+
+ if (image == NULL || image[0] == '\0')
+ goto done;
+
+ q_image = g_shell_quote (image);
+
+ if (name != NULL && name[0] != '\0')
+ q_name = g_shell_quote (name);
+
+ if (command != NULL && command[0] != '\0')
+ q_cmd = g_shell_quote (command);
+
+ if (data->view == DOCKER_VIEW_CONTAINERS_ITEMS && data->current_project != NULL
+ && !is_ungrouped_project (data->current_project))
+ q_project = g_shell_quote (data->current_project);
+
+ cmd = g_strdup ("docker run ");
+
+ if (detach)
+ {
+ char *tmp = g_strconcat (cmd, "-d ", (char *) NULL);
+ g_free (cmd);
+ cmd = tmp;
+ }
+
+ if (q_name != NULL)
+ {
+ char *tmp = g_strconcat (cmd, "--name ", q_name, " ", (char *) NULL);
+ g_free (cmd);
+ cmd = tmp;
+ }
+
+ if (q_project != NULL)
+ {
+ char *tmp = g_strconcat (cmd, "--label com.docker.compose.project=", q_project, " ",
+ (char *) NULL);
+ g_free (cmd);
+ cmd = tmp;
+ }
+
+ {
+ char *tmp = g_strconcat (cmd, q_image, (char *) NULL);
+ g_free (cmd);
+ cmd = tmp;
+ }
+
+ if (q_cmd != NULL)
+ {
+ char *tmp = g_strconcat (cmd, " sh -c ", q_cmd, (char *) NULL);
+ g_free (cmd);
+ cmd = tmp;
+ }
+
+ if (!run_cmd (cmd, &output, &err_text))
+ {
+ if (err_text != NULL && err_text[0] != '\0')
+ message (D_ERROR, MSG_ERROR, "%s", err_text);
+ goto done;
+ }
+
+ reload_items (data);
+ result = MC_PPR_OK;
+
+done:
+ g_free (image);
+ g_free (name);
+ g_free (command);
+ g_free (q_image);
+ g_free (q_name);
+ g_free (q_cmd);
+ g_free (q_project);
+ g_free (cmd);
+ g_free (output);
+ g_free (err_text);
+
+ return result;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+/*** public functions ****************************************************************************/
+/* --------------------------------------------------------------------------------------------- */
+
+const mc_panel_plugin_t *mc_panel_plugin_register (void);
+
+const mc_panel_plugin_t *
+mc_panel_plugin_register (void)
+{
+ return &docker_plugin;
+}
+
+/* --------------------------------------------------------------------------------------------- */
diff --git a/src/panel-plugins/hello/Makefile.am b/src/panel-plugins/hello/Makefile.am
new file mode 100644
index 0000000000..9342389e8a
--- /dev/null
+++ b/src/panel-plugins/hello/Makefile.am
@@ -0,0 +1,7 @@
+panelplugindir = $(panel_plugins_dir)
+panelplugin_LTLIBRARIES = mc-panel-hello.la
+
+mc_panel_hello_la_SOURCES = hello.c
+mc_panel_hello_la_CPPFLAGS = $(GLIB_CFLAGS) -I$(top_srcdir)
+mc_panel_hello_la_LDFLAGS = -module -avoid-version
+mc_panel_hello_la_LIBADD = $(GLIB_LIBS)
diff --git a/src/panel-plugins/hello/hello.c b/src/panel-plugins/hello/hello.c
new file mode 100644
index 0000000000..97f67074e4
--- /dev/null
+++ b/src/panel-plugins/hello/hello.c
@@ -0,0 +1,263 @@
+/*
+ Hello World panel plugin — demonstrates the panel plugin API.
+
+ Copyright (C) 2025
+ Free Software Foundation, Inc.
+
+ Written by:
+ Ilia Maslakov , 2026.
+
+ This file is part of the Midnight Commander.
+
+ The Midnight Commander is free software: you can redistribute it
+ and/or modify it under the terms of the GNU General Public License as
+ published by the Free Software Foundation, either version 3 of the License,
+ or (at your option) any later version.
+
+ The Midnight Commander is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+ */
+
+#include
+
+#include
+#include
+#include
+#include
+
+#include "lib/global.h"
+#include "lib/panel-plugin.h"
+
+/* dir_list and dir_list_append are in the filemanager dir.h, but we need the
+ dir_list_append prototype. Since we're a loadable module, we rely on the
+ host exporting it (mc is built with -export-dynamic when HAVE_GMODULE). */
+#include "src/filemanager/dir.h"
+
+/*** file scope type declarations ****************************************************************/
+
+typedef struct
+{
+ mc_panel_host_t *host;
+ gboolean in_subdir; /* TRUE when user entered "subdir" */
+ GHashTable *deleted; /* set of "deleted" filenames */
+} hello_data_t;
+
+/*** forward declarations (file scope functions) *************************************************/
+
+static void *hello_open (mc_panel_host_t *host, const char *open_path);
+static void hello_close (void *plugin_data);
+static mc_pp_result_t hello_get_items (void *plugin_data, void *list_ptr);
+static mc_pp_result_t hello_chdir (void *plugin_data, const char *path);
+static mc_pp_result_t hello_get_local_copy (void *plugin_data, const char *fname,
+ char **local_path);
+static mc_pp_result_t hello_delete_items (void *plugin_data, const char **names, int count);
+static const char *hello_get_title (void *plugin_data);
+
+/*** file scope variables ************************************************************************/
+
+static const mc_panel_plugin_t hello_plugin = {
+ .api_version = MC_PANEL_PLUGIN_API_VERSION,
+ .name = "hello",
+ .display_name = "Hello World Plugin",
+ .proto = "HelloWorld",
+ .prefix = NULL,
+ .flags = MC_PPF_NAVIGATE | MC_PPF_GET_FILES | MC_PPF_DELETE | MC_PPF_CUSTOM_TITLE,
+
+ .open = hello_open,
+ .close = hello_close,
+ .get_items = hello_get_items,
+
+ .chdir = hello_chdir,
+ .enter = NULL,
+ .get_local_copy = hello_get_local_copy,
+ .delete_items = hello_delete_items,
+ .get_title = hello_get_title,
+ .handle_key = NULL,
+};
+
+/*** file scope functions ************************************************************************/
+
+static void
+add_fake_entry (dir_list *list, const char *name, mode_t mode, off_t size)
+{
+ struct stat st;
+
+ memset (&st, 0, sizeof (st));
+ st.st_mode = mode;
+ st.st_size = size;
+ st.st_mtime = time (NULL);
+ st.st_uid = getuid ();
+ st.st_gid = getgid ();
+ st.st_nlink = 1;
+
+ dir_list_append (list, name, &st, S_ISDIR (mode), FALSE);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static void *
+hello_open (mc_panel_host_t *host, const char *open_path)
+{
+ hello_data_t *data;
+
+ (void) open_path;
+
+ data = g_new0 (hello_data_t, 1);
+ data->host = host;
+ data->in_subdir = FALSE;
+ data->deleted = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, NULL);
+
+ return data;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static void
+hello_close (void *plugin_data)
+{
+ hello_data_t *data = (hello_data_t *) plugin_data;
+
+ g_hash_table_destroy (data->deleted);
+ g_free (data);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static mc_pp_result_t
+hello_get_items (void *plugin_data, void *list_ptr)
+{
+ dir_list *list = (dir_list *) list_ptr;
+ hello_data_t *data = (hello_data_t *) plugin_data;
+
+ /* Note: dir_list_init() already creates the ".." entry at index 0,
+ so the plugin must NOT add it again. */
+
+ if (data->in_subdir)
+ {
+ if (!g_hash_table_contains (data->deleted, "deep-file.txt"))
+ add_fake_entry (list, "deep-file.txt", S_IFREG | 0644, 256);
+ if (!g_hash_table_contains (data->deleted, "another.dat"))
+ add_fake_entry (list, "another.dat", S_IFREG | 0644, 1024);
+ }
+ else
+ {
+ if (!g_hash_table_contains (data->deleted, "hello.txt"))
+ add_fake_entry (list, "hello.txt", S_IFREG | 0644, 42);
+ if (!g_hash_table_contains (data->deleted, "world.txt"))
+ add_fake_entry (list, "world.txt", S_IFREG | 0644, 100);
+ add_fake_entry (list, "subdir", S_IFDIR | 0755, 4096);
+ }
+
+ return MC_PPR_OK;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static mc_pp_result_t
+hello_chdir (void *plugin_data, const char *path)
+{
+ hello_data_t *data = (hello_data_t *) plugin_data;
+
+ if (strcmp (path, "..") == 0)
+ {
+ if (data->in_subdir)
+ {
+ data->in_subdir = FALSE;
+ return MC_PPR_OK;
+ }
+ return MC_PPR_NOT_SUPPORTED; /* close plugin */
+ }
+
+ if (strcmp (path, "subdir") == 0 && !data->in_subdir)
+ {
+ data->in_subdir = TRUE;
+ return MC_PPR_OK;
+ }
+
+ return MC_PPR_FAILED;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static mc_pp_result_t
+hello_get_local_copy (void *plugin_data, const char *fname, char **local_path)
+{
+ GError *error = NULL;
+ int fd;
+ const char *content;
+
+ (void) plugin_data;
+
+ if (strcmp (fname, "hello.txt") == 0)
+ content = "Hello, World!\nThis is content from the Hello World panel plugin.\n";
+ else if (strcmp (fname, "world.txt") == 0)
+ content = "This is world.txt\nAnother virtual file from the plugin.\n";
+ else if (strcmp (fname, "deep-file.txt") == 0)
+ content = "Deep inside the subdir.\n";
+ else if (strcmp (fname, "another.dat") == 0)
+ content = "Binary-ish data from the plugin.\n";
+ else
+ return MC_PPR_FAILED;
+
+ fd = g_file_open_tmp ("mc-pp-XXXXXX", local_path, &error);
+ if (fd == -1)
+ {
+ g_error_free (error);
+ return MC_PPR_FAILED;
+ }
+
+ if (write (fd, content, strlen (content)) == -1)
+ {
+ close (fd);
+ unlink (*local_path);
+ g_free (*local_path);
+ *local_path = NULL;
+ return MC_PPR_FAILED;
+ }
+ close (fd);
+ return MC_PPR_OK;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static mc_pp_result_t
+hello_delete_items (void *plugin_data, const char **names, int count)
+{
+ hello_data_t *data = (hello_data_t *) plugin_data;
+ int i;
+
+ for (i = 0; i < count; i++)
+ g_hash_table_add (data->deleted, g_strdup (names[i]));
+
+ return MC_PPR_OK;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static const char *
+hello_get_title (void *plugin_data)
+{
+ hello_data_t *data = (hello_data_t *) plugin_data;
+
+ return data->in_subdir ? "/subdir" : "/";
+}
+
+/* --------------------------------------------------------------------------------------------- */
+/*** public functions ****************************************************************************/
+/* --------------------------------------------------------------------------------------------- */
+
+/* Entry point called by the panel plugin loader */
+const mc_panel_plugin_t *mc_panel_plugin_register (void);
+
+const mc_panel_plugin_t *
+mc_panel_plugin_register (void)
+{
+ return &hello_plugin;
+}
+
+/* --------------------------------------------------------------------------------------------- */
diff --git a/src/panel-plugins/samba/Makefile.am b/src/panel-plugins/samba/Makefile.am
new file mode 100644
index 0000000000..19516b264d
--- /dev/null
+++ b/src/panel-plugins/samba/Makefile.am
@@ -0,0 +1,7 @@
+panelplugindir = $(panel_plugins_dir)
+panelplugin_LTLIBRARIES = mc-panel-samba.la
+
+mc_panel_samba_la_SOURCES = samba.c
+mc_panel_samba_la_CPPFLAGS = $(GLIB_CFLAGS) $(SMBCLIENT_CFLAGS) -I$(top_srcdir)
+mc_panel_samba_la_LDFLAGS = -module -avoid-version
+mc_panel_samba_la_LIBADD = $(GLIB_LIBS) $(SMBCLIENT_LIBS)
diff --git a/src/panel-plugins/samba/samba.c b/src/panel-plugins/samba/samba.c
new file mode 100644
index 0000000000..6c3d2a12e8
--- /dev/null
+++ b/src/panel-plugins/samba/samba.c
@@ -0,0 +1,1011 @@
+/*
+ Samba network browser panel plugin (libsmbclient).
+
+ Copyright (C) 2025
+ Free Software Foundation, Inc.
+
+ Written by:
+ Ilia Maslakov , 2026.
+
+ This file is part of the Midnight Commander.
+
+ The Midnight Commander is free software: you can redistribute it
+ and/or modify it under the terms of the GNU General Public License as
+ published by the Free Software Foundation, either version 3 of the License,
+ or (at your option) any later version.
+
+ The Midnight Commander is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+ */
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+#include "lib/global.h"
+#include "lib/panel-plugin.h"
+#include "lib/widget.h"
+
+#include "src/filemanager/dir.h"
+
+/*** file scope type declarations ****************************************************************/
+
+/* A saved connection bookmark */
+typedef struct
+{
+ char *label; /* display name (ini group key) */
+ char *server;
+ char *share; /* default share/path, may be empty */
+ char *username; /* may include DOMAIN\user */
+ char *password;
+ char *workgroup;
+} smb_connection_t;
+
+/* A directory entry (file/dir/share inside a connection) */
+typedef struct
+{
+ char *name;
+ unsigned int smbc_type; /* SMBC_FILE_SHARE, SMBC_DIR, SMBC_FILE, etc. */
+ struct stat st;
+} smb_entry_t;
+
+typedef struct
+{
+ mc_panel_host_t *host;
+ SMBCCTX *ctx;
+
+ /* Navigation state */
+ gboolean at_root; /* TRUE = showing saved connections list */
+ char *current_url; /* "smb://SERVER/SHARE/path" when not at root */
+ GPtrArray *entries; /* smb_entry_t* when browsing; NULL at root */
+
+ /* Saved connections */
+ GPtrArray *connections; /* smb_connection_t* — loaded from ini */
+ char *connections_file; /* path to ini file */
+
+ /* Active connection credentials (for auth callback) */
+ char *auth_username;
+ char *auth_password;
+ char *auth_workgroup;
+
+ char *title_buf;
+} samba_data_t;
+
+/*** forward declarations (file scope functions) *************************************************/
+
+static void *samba_open (mc_panel_host_t *host, const char *open_path);
+static void samba_close (void *plugin_data);
+static mc_pp_result_t samba_get_items (void *plugin_data, void *list_ptr);
+static mc_pp_result_t samba_chdir (void *plugin_data, const char *path);
+static mc_pp_result_t samba_enter (void *plugin_data, const char *name, const struct stat *st);
+static mc_pp_result_t samba_get_local_copy (void *plugin_data, const char *fname,
+ char **local_path);
+static mc_pp_result_t samba_delete_items (void *plugin_data, const char **names, int count);
+static const char *samba_get_title (void *plugin_data);
+static mc_pp_result_t samba_create_item (void *plugin_data);
+
+/*** file scope variables ************************************************************************/
+
+static const mc_panel_plugin_t samba_plugin = {
+ .api_version = MC_PANEL_PLUGIN_API_VERSION,
+ .name = "samba",
+ .display_name = "Samba network",
+ .proto = "smb",
+ .prefix = NULL,
+ .flags =
+ MC_PPF_NAVIGATE | MC_PPF_GET_FILES | MC_PPF_DELETE | MC_PPF_CUSTOM_TITLE | MC_PPF_CREATE,
+
+ .open = samba_open,
+ .close = samba_close,
+ .get_items = samba_get_items,
+
+ .chdir = samba_chdir,
+ .enter = samba_enter,
+ .get_local_copy = samba_get_local_copy,
+ .delete_items = samba_delete_items,
+ .get_title = samba_get_title,
+ .handle_key = NULL,
+ .create_item = samba_create_item,
+};
+
+/*** file scope functions ************************************************************************/
+
+static void
+add_entry (dir_list *list, const char *name, mode_t mode, off_t size, time_t mtime)
+{
+ struct stat st;
+
+ memset (&st, 0, sizeof (st));
+ st.st_mode = mode;
+ st.st_size = size;
+ st.st_mtime = mtime;
+ st.st_uid = getuid ();
+ st.st_gid = getgid ();
+ st.st_nlink = 1;
+
+ dir_list_append (list, name, &st, S_ISDIR (mode), FALSE);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static void
+smb_entry_free (gpointer p)
+{
+ smb_entry_t *e = (smb_entry_t *) p;
+
+ g_free (e->name);
+ g_free (e);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static void
+smb_connection_free (gpointer p)
+{
+ smb_connection_t *c = (smb_connection_t *) p;
+
+ g_free (c->label);
+ g_free (c->server);
+ g_free (c->share);
+ g_free (c->username);
+ g_free (c->password);
+ g_free (c->workgroup);
+ g_free (c);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+/* Connection storage (ini file) */
+/* --------------------------------------------------------------------------------------------- */
+
+static char *
+get_connections_file_path (void)
+{
+ return g_build_filename (g_get_user_config_dir (), "mc", "smb-connections.ini", (char *) NULL);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static GPtrArray *
+load_connections (const char *filepath)
+{
+ GPtrArray *arr;
+ GKeyFile *kf;
+ gchar **groups;
+ gsize n_groups, i;
+
+ arr = g_ptr_array_new_with_free_func (smb_connection_free);
+
+ kf = g_key_file_new ();
+ if (!g_key_file_load_from_file (kf, filepath, G_KEY_FILE_NONE, NULL))
+ {
+ g_key_file_free (kf);
+ return arr;
+ }
+
+ groups = g_key_file_get_groups (kf, &n_groups);
+ for (i = 0; i < n_groups; i++)
+ {
+ smb_connection_t *conn;
+
+ conn = g_new0 (smb_connection_t, 1);
+ conn->label = g_strdup (groups[i]);
+ conn->server = g_key_file_get_string (kf, groups[i], "server", NULL);
+ conn->share = g_key_file_get_string (kf, groups[i], "share", NULL);
+ conn->username = g_key_file_get_string (kf, groups[i], "username", NULL);
+ conn->password = g_key_file_get_string (kf, groups[i], "password", NULL);
+ conn->workgroup = g_key_file_get_string (kf, groups[i], "workgroup", NULL);
+
+ if (conn->server == NULL || conn->server[0] == '\0')
+ {
+ smb_connection_free (conn);
+ continue;
+ }
+
+ g_ptr_array_add (arr, conn);
+ }
+
+ g_strfreev (groups);
+ g_key_file_free (kf);
+ return arr;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static gboolean
+save_connections (const char *filepath, GPtrArray *connections)
+{
+ GKeyFile *kf;
+ gchar *data;
+ gsize length;
+ gboolean ok;
+ gchar *dir;
+ guint i;
+
+ kf = g_key_file_new ();
+
+ for (i = 0; i < connections->len; i++)
+ {
+ const smb_connection_t *conn =
+ (const smb_connection_t *) g_ptr_array_index (connections, i);
+
+ g_key_file_set_string (kf, conn->label, "server", conn->server);
+ if (conn->share != NULL)
+ g_key_file_set_string (kf, conn->label, "share", conn->share);
+ if (conn->username != NULL)
+ g_key_file_set_string (kf, conn->label, "username", conn->username);
+ if (conn->password != NULL)
+ g_key_file_set_string (kf, conn->label, "password", conn->password);
+ if (conn->workgroup != NULL)
+ g_key_file_set_string (kf, conn->label, "workgroup", conn->workgroup);
+ }
+
+ data = g_key_file_to_data (kf, &length, NULL);
+ g_key_file_free (kf);
+
+ if (data == NULL)
+ return FALSE;
+
+ /* Ensure directory exists */
+ dir = g_path_get_dirname (filepath);
+ g_mkdir_with_parents (dir, 0700);
+ g_free (dir);
+
+ ok = g_file_set_contents (filepath, data, (gssize) length, NULL);
+ g_free (data);
+ return ok;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static const smb_connection_t *
+find_connection (const samba_data_t *data, const char *label)
+{
+ guint i;
+
+ for (i = 0; i < data->connections->len; i++)
+ {
+ const smb_connection_t *c =
+ (const smb_connection_t *) g_ptr_array_index (data->connections, i);
+
+ if (strcmp (c->label, label) == 0)
+ return c;
+ }
+
+ return NULL;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+/* libsmbclient helpers */
+/* --------------------------------------------------------------------------------------------- */
+
+static void
+smb_auth_cb (SMBCCTX *ctx, const char *server, const char *share,
+ char *wg, int wg_len, char *un, int un_len, char *pw, int pw_len)
+{
+ samba_data_t *data;
+
+ (void) server;
+ (void) share;
+
+ data = (samba_data_t *) smbc_getOptionUserData (ctx);
+ if (data == NULL)
+ return;
+
+ if (data->auth_workgroup != NULL)
+ g_strlcpy (wg, data->auth_workgroup, (gsize) wg_len);
+ if (data->auth_username != NULL)
+ g_strlcpy (un, data->auth_username, (gsize) un_len);
+ if (data->auth_password != NULL)
+ g_strlcpy (pw, data->auth_password, (gsize) pw_len);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static gboolean
+smb_is_inside_share (const char *url)
+{
+ const char *p;
+ int slash_count = 0;
+
+ p = url + 6; /* skip "smb://" */
+ while (*p != '\0')
+ {
+ if (*p == '/')
+ slash_count++;
+ p++;
+ }
+
+ return (slash_count >= 1);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static GPtrArray *
+smb_load_entries (samba_data_t *data)
+{
+ GPtrArray *arr;
+ int dh;
+ struct smbc_dirent *dirent;
+ gboolean inside_share;
+
+ arr = g_ptr_array_new_with_free_func (smb_entry_free);
+
+ smbc_set_context (data->ctx);
+ dh = smbc_opendir (data->current_url);
+ if (dh < 0)
+ return arr;
+
+ inside_share = smb_is_inside_share (data->current_url);
+
+ while ((dirent = smbc_readdir (dh)) != NULL)
+ {
+ smb_entry_t *entry;
+
+ if (strcmp (dirent->name, ".") == 0 || strcmp (dirent->name, "..") == 0)
+ continue;
+
+ /* skip IPC$, printer, comms shares */
+ if (dirent->smbc_type == SMBC_IPC_SHARE || dirent->smbc_type == SMBC_PRINTER_SHARE
+ || dirent->smbc_type == SMBC_COMMS_SHARE)
+ continue;
+
+ entry = g_new0 (smb_entry_t, 1);
+ entry->name = g_strdup (dirent->name);
+ entry->smbc_type = dirent->smbc_type;
+
+ if (inside_share
+ && (dirent->smbc_type == SMBC_FILE || dirent->smbc_type == SMBC_DIR
+ || dirent->smbc_type == SMBC_LINK))
+ {
+ char *full_url;
+
+ full_url = g_strdup_printf ("%s/%s", data->current_url, dirent->name);
+ if (smbc_stat (full_url, &entry->st) != 0)
+ {
+ memset (&entry->st, 0, sizeof (entry->st));
+ entry->st.st_mtime = time (NULL);
+ }
+ g_free (full_url);
+ }
+ else
+ {
+ memset (&entry->st, 0, sizeof (entry->st));
+ entry->st.st_mtime = time (NULL);
+ }
+
+ g_ptr_array_add (arr, entry);
+ }
+
+ smbc_closedir (dh);
+ return arr;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static char *
+smb_url_up (const char *url)
+{
+ const char *after_scheme;
+ const char *last_slash;
+
+ after_scheme = url + 6; /* past "smb://" */
+
+ if (*after_scheme == '\0')
+ return NULL;
+
+ last_slash = strrchr (after_scheme, '/');
+ if (last_slash == NULL)
+ return g_strdup ("smb://");
+
+ return g_strndup (url, (gsize) (last_slash - url));
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static gboolean
+smb_entry_is_dir (unsigned int smbc_type)
+{
+ return (smbc_type == SMBC_WORKGROUP || smbc_type == SMBC_SERVER
+ || smbc_type == SMBC_FILE_SHARE || smbc_type == SMBC_DIR);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static const smb_entry_t *
+find_entry (const samba_data_t *data, const char *name)
+{
+ guint i;
+
+ if (data->entries == NULL)
+ return NULL;
+
+ for (i = 0; i < data->entries->len; i++)
+ {
+ const smb_entry_t *e = (const smb_entry_t *) g_ptr_array_index (data->entries, i);
+
+ if (strcmp (e->name, name) == 0)
+ return e;
+ }
+
+ return NULL;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static void
+set_auth_from_connection (samba_data_t *data, const smb_connection_t *conn)
+{
+ g_free (data->auth_username);
+ g_free (data->auth_password);
+ g_free (data->auth_workgroup);
+
+ data->auth_username = g_strdup (conn->username);
+ data->auth_password = g_strdup (conn->password);
+ data->auth_workgroup = g_strdup (conn->workgroup);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static mc_pp_result_t
+enter_connection (samba_data_t *data, const smb_connection_t *conn)
+{
+ set_auth_from_connection (data, conn);
+
+ g_free (data->current_url);
+ if (conn->share != NULL && conn->share[0] != '\0')
+ data->current_url = g_strdup_printf ("smb://%s/%s", conn->server, conn->share);
+ else
+ data->current_url = g_strdup_printf ("smb://%s", conn->server);
+
+ if (data->entries != NULL)
+ g_ptr_array_free (data->entries, TRUE);
+
+ smbc_set_context (data->ctx);
+ data->entries = smb_load_entries (data);
+ data->at_root = FALSE;
+
+ return MC_PPR_OK;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+/* Connection dialog */
+/* --------------------------------------------------------------------------------------------- */
+
+static gboolean
+show_connection_dialog (char **label, char **server, char **share,
+ char **username, char **password)
+{
+ /* clang-format off */
+ quick_widget_t quick_widgets[] = {
+ QUICK_LABELED_INPUT (N_("Name:"), input_label_above,
+ *label != NULL ? *label : "", "smb-conn-label",
+ label, NULL, FALSE, FALSE, INPUT_COMPLETE_NONE),
+ QUICK_SEPARATOR (FALSE),
+ QUICK_LABELED_INPUT (N_("Server:"), input_label_above,
+ *server != NULL ? *server : "", "smb-conn-server",
+ server, NULL, FALSE, FALSE, INPUT_COMPLETE_HOSTNAMES),
+ QUICK_SEPARATOR (FALSE),
+ QUICK_LABELED_INPUT (N_("Share/path:"), input_label_above,
+ *share != NULL ? *share : "", "smb-conn-share",
+ share, NULL, FALSE, FALSE, INPUT_COMPLETE_NONE),
+ QUICK_SEPARATOR (FALSE),
+ QUICK_LABELED_INPUT (N_("Username (DOMAIN\\user):"), input_label_above,
+ *username != NULL ? *username : "", "smb-conn-user",
+ username, NULL, FALSE, FALSE, INPUT_COMPLETE_NONE),
+ QUICK_SEPARATOR (FALSE),
+ QUICK_LABELED_INPUT (N_("Password:"), input_label_above,
+ *password != NULL ? *password : "", "smb-conn-pass",
+ password, NULL, TRUE, TRUE, INPUT_COMPLETE_NONE),
+ QUICK_BUTTONS_OK_CANCEL,
+ QUICK_END,
+ };
+ /* clang-format on */
+
+ WRect r = { -1, -1, 0, 56 };
+
+ quick_dialog_t qdlg = {
+ .rect = r,
+ .title = N_("SMB Connection"),
+ .help = "[Samba Plugin]",
+ .widgets = quick_widgets,
+ .callback = NULL,
+ .mouse_callback = NULL,
+ };
+
+ return (quick_dialog (&qdlg) == B_ENTER);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+/* Plugin callbacks */
+/* --------------------------------------------------------------------------------------------- */
+
+static void *
+samba_open (mc_panel_host_t *host, const char *open_path)
+{
+ samba_data_t *data;
+
+ (void) open_path;
+
+ data = g_new0 (samba_data_t, 1);
+ data->host = host;
+ data->at_root = TRUE;
+ data->current_url = NULL;
+ data->entries = NULL;
+ data->title_buf = NULL;
+
+ data->auth_username = NULL;
+ data->auth_password = NULL;
+ data->auth_workgroup = NULL;
+
+ /* Load saved connections */
+ data->connections_file = get_connections_file_path ();
+ data->connections = load_connections (data->connections_file);
+
+ /* Create libsmbclient context */
+ data->ctx = smbc_new_context ();
+ if (data->ctx == NULL)
+ {
+ g_ptr_array_free (data->connections, TRUE);
+ g_free (data->connections_file);
+ g_free (data);
+ return NULL;
+ }
+
+ smbc_setDebug (data->ctx, 0);
+ smbc_setFunctionAuthDataWithContext (data->ctx, smb_auth_cb);
+ smbc_setOptionUserData (data->ctx, data);
+
+ if (smbc_init_context (data->ctx) == NULL)
+ {
+ smbc_free_context (data->ctx, 0);
+ g_ptr_array_free (data->connections, TRUE);
+ g_free (data->connections_file);
+ g_free (data);
+ return NULL;
+ }
+
+ return data;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static void
+samba_close (void *plugin_data)
+{
+ samba_data_t *data = (samba_data_t *) plugin_data;
+
+ if (data->entries != NULL)
+ g_ptr_array_free (data->entries, TRUE);
+
+ g_ptr_array_free (data->connections, TRUE);
+
+ if (data->ctx != NULL)
+ smbc_free_context (data->ctx, 1);
+
+ g_free (data->current_url);
+ g_free (data->title_buf);
+ g_free (data->connections_file);
+ g_free (data->auth_username);
+ g_free (data->auth_password);
+ g_free (data->auth_workgroup);
+ g_free (data);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static mc_pp_result_t
+samba_get_items (void *plugin_data, void *list_ptr)
+{
+ dir_list *list = (dir_list *) list_ptr;
+ samba_data_t *data = (samba_data_t *) plugin_data;
+ guint i;
+
+ if (data->at_root)
+ {
+ /* Show saved connections as directories */
+ for (i = 0; i < data->connections->len; i++)
+ {
+ const smb_connection_t *conn =
+ (const smb_connection_t *) g_ptr_array_index (data->connections, i);
+
+ add_entry (list, conn->label, S_IFDIR | 0755, 0, time (NULL));
+ }
+ return MC_PPR_OK;
+ }
+
+ /* Inside a connection — show SMB entries */
+ if (data->entries != NULL)
+ {
+ for (i = 0; i < data->entries->len; i++)
+ {
+ const smb_entry_t *e = (const smb_entry_t *) g_ptr_array_index (data->entries, i);
+ mode_t mode;
+ off_t size;
+ time_t mtime;
+
+ if (smb_entry_is_dir (e->smbc_type))
+ {
+ mode = S_IFDIR | 0755;
+ size = 0;
+ }
+ else
+ {
+ mode = S_IFREG | 0644;
+ size = e->st.st_size;
+ }
+
+ mtime = (e->st.st_mtime != 0) ? e->st.st_mtime : time (NULL);
+ add_entry (list, e->name, mode, size, mtime);
+ }
+ }
+
+ return MC_PPR_OK;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static mc_pp_result_t
+samba_chdir (void *plugin_data, const char *path)
+{
+ samba_data_t *data = (samba_data_t *) plugin_data;
+
+ if (strcmp (path, "..") == 0)
+ {
+ if (data->at_root)
+ return MC_PPR_NOT_SUPPORTED; /* close plugin */
+
+ /* Are we at the connection's base URL (smb://SERVER or smb://SERVER/SHARE)? */
+ {
+ char *parent;
+
+ parent = smb_url_up (data->current_url);
+ if (parent == NULL)
+ {
+ /* At smb:// level — go back to root (connections list) */
+ data->at_root = TRUE;
+ g_free (data->current_url);
+ data->current_url = NULL;
+
+ if (data->entries != NULL)
+ {
+ g_ptr_array_free (data->entries, TRUE);
+ data->entries = NULL;
+ }
+ return MC_PPR_OK;
+ }
+
+ /* Navigate up within the connection */
+ g_free (data->current_url);
+ data->current_url = parent;
+
+ if (data->entries != NULL)
+ g_ptr_array_free (data->entries, TRUE);
+
+ smbc_set_context (data->ctx);
+ data->entries = smb_load_entries (data);
+ return MC_PPR_OK;
+ }
+ }
+
+ /* At root: enter a saved connection */
+ if (data->at_root)
+ {
+ const smb_connection_t *conn;
+
+ conn = find_connection (data, path);
+ if (conn == NULL)
+ return MC_PPR_FAILED;
+
+ return enter_connection (data, conn);
+ }
+
+ /* Inside connection: navigate into subdir/share */
+ {
+ const smb_entry_t *entry;
+ char *new_url;
+
+ entry = find_entry (data, path);
+ if (entry == NULL || !smb_entry_is_dir (entry->smbc_type))
+ return MC_PPR_FAILED;
+
+ if (g_str_has_suffix (data->current_url, "/"))
+ new_url = g_strdup_printf ("%s%s", data->current_url, path);
+ else
+ new_url = g_strdup_printf ("%s/%s", data->current_url, path);
+
+ g_free (data->current_url);
+ data->current_url = new_url;
+
+ if (data->entries != NULL)
+ g_ptr_array_free (data->entries, TRUE);
+
+ smbc_set_context (data->ctx);
+ data->entries = smb_load_entries (data);
+ return MC_PPR_OK;
+ }
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static mc_pp_result_t
+samba_enter (void *plugin_data, const char *name, const struct stat *st)
+{
+ samba_data_t *data = (samba_data_t *) plugin_data;
+
+ (void) st;
+
+ /* At root: enter saved connection */
+ if (data->at_root)
+ {
+ const smb_connection_t *conn;
+
+ conn = find_connection (data, name);
+ if (conn == NULL)
+ return MC_PPR_FAILED;
+
+ return enter_connection (data, conn);
+ }
+
+ /* Inside connection */
+ {
+ const smb_entry_t *entry;
+
+ entry = find_entry (data, name);
+ if (entry == NULL)
+ return MC_PPR_FAILED;
+
+ if (smb_entry_is_dir (entry->smbc_type))
+ {
+ char *new_url;
+
+ if (g_str_has_suffix (data->current_url, "/"))
+ new_url = g_strdup_printf ("%s%s", data->current_url, name);
+ else
+ new_url = g_strdup_printf ("%s/%s", data->current_url, name);
+
+ g_free (data->current_url);
+ data->current_url = new_url;
+
+ if (data->entries != NULL)
+ g_ptr_array_free (data->entries, TRUE);
+
+ smbc_set_context (data->ctx);
+ data->entries = smb_load_entries (data);
+ return MC_PPR_OK;
+ }
+
+ /* File — let mc handle via get_local_copy / viewer */
+ return MC_PPR_NOT_SUPPORTED;
+ }
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static mc_pp_result_t
+samba_get_local_copy (void *plugin_data, const char *fname, char **local_path)
+{
+ samba_data_t *data = (samba_data_t *) plugin_data;
+ char *smb_url;
+ int smb_fd;
+ int local_fd;
+ GError *error = NULL;
+ char buf[8192];
+ ssize_t n;
+
+ if (data->at_root || data->current_url == NULL)
+ return MC_PPR_FAILED;
+
+ smb_url = g_strdup_printf ("%s/%s", data->current_url, fname);
+
+ smbc_set_context (data->ctx);
+ smb_fd = smbc_open (smb_url, O_RDONLY, 0);
+ g_free (smb_url);
+
+ if (smb_fd < 0)
+ return MC_PPR_FAILED;
+
+ local_fd = g_file_open_tmp ("mc-pp-smb-XXXXXX", local_path, &error);
+ if (local_fd == -1)
+ {
+ if (error != NULL)
+ g_error_free (error);
+ smbc_close (smb_fd);
+ return MC_PPR_FAILED;
+ }
+
+ while ((n = smbc_read (smb_fd, buf, sizeof (buf))) > 0)
+ {
+ if (write (local_fd, buf, (size_t) n) != n)
+ {
+ close (local_fd);
+ smbc_close (smb_fd);
+ unlink (*local_path);
+ g_free (*local_path);
+ *local_path = NULL;
+ return MC_PPR_FAILED;
+ }
+ }
+
+ close (local_fd);
+ smbc_close (smb_fd);
+
+ if (n < 0)
+ {
+ unlink (*local_path);
+ g_free (*local_path);
+ *local_path = NULL;
+ return MC_PPR_FAILED;
+ }
+
+ return MC_PPR_OK;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static mc_pp_result_t
+samba_delete_items (void *plugin_data, const char **names, int count)
+{
+ samba_data_t *data = (samba_data_t *) plugin_data;
+ int i;
+
+ if (data->at_root)
+ {
+ /* Delete saved connections */
+ for (i = 0; i < count; i++)
+ {
+ guint j;
+
+ for (j = 0; j < data->connections->len; j++)
+ {
+ const smb_connection_t *conn =
+ (const smb_connection_t *) g_ptr_array_index (data->connections, j);
+
+ if (strcmp (conn->label, names[i]) == 0)
+ {
+ g_ptr_array_remove_index (data->connections, j);
+ break;
+ }
+ }
+ }
+
+ save_connections (data->connections_file, data->connections);
+ return MC_PPR_OK;
+ }
+
+ /* Inside connection: delete files/dirs on SMB */
+ smbc_set_context (data->ctx);
+
+ for (i = 0; i < count; i++)
+ {
+ const smb_entry_t *entry;
+ char *url;
+
+ entry = find_entry (data, names[i]);
+ url = g_strdup_printf ("%s/%s", data->current_url, names[i]);
+
+ if (entry != NULL && entry->smbc_type == SMBC_DIR)
+ smbc_rmdir (url);
+ else
+ smbc_unlink (url);
+
+ g_free (url);
+ }
+
+ if (data->entries != NULL)
+ g_ptr_array_free (data->entries, TRUE);
+ data->entries = smb_load_entries (data);
+
+ return MC_PPR_OK;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static const char *
+samba_get_title (void *plugin_data)
+{
+ samba_data_t *data = (samba_data_t *) plugin_data;
+
+ g_free (data->title_buf);
+
+ if (data->at_root || data->current_url == NULL)
+ {
+ data->title_buf = g_strdup ("/");
+ return data->title_buf;
+ }
+
+ /* Strip "smb:/" to get path for display */
+ if (strlen (data->current_url) <= 5)
+ data->title_buf = g_strdup ("/");
+ else
+ data->title_buf = g_strdup (data->current_url + 5);
+
+ return data->title_buf;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static mc_pp_result_t
+samba_create_item (void *plugin_data)
+{
+ samba_data_t *data = (samba_data_t *) plugin_data;
+ smb_connection_t *conn;
+ char *label = NULL;
+ char *server = NULL;
+ char *share = NULL;
+ char *username = NULL;
+ char *password = NULL;
+
+ if (!data->at_root)
+ return MC_PPR_NOT_SUPPORTED;
+
+ if (!show_connection_dialog (&label, &server, &share, &username, &password))
+ {
+ g_free (label);
+ g_free (server);
+ g_free (share);
+ g_free (username);
+ g_free (password);
+ return MC_PPR_FAILED;
+ }
+
+ if (label == NULL || label[0] == '\0' || server == NULL || server[0] == '\0')
+ {
+ g_free (label);
+ g_free (server);
+ g_free (share);
+ g_free (username);
+ g_free (password);
+ return MC_PPR_FAILED;
+ }
+
+ conn = g_new0 (smb_connection_t, 1);
+ conn->label = label;
+ conn->server = server;
+ conn->share = share;
+ conn->username = username;
+ conn->password = password;
+
+ /* Extract workgroup from DOMAIN\user if present */
+ if (username != NULL)
+ {
+ char *backslash;
+
+ backslash = strchr (username, '\\');
+ if (backslash != NULL)
+ {
+ conn->workgroup = g_strndup (username, (gsize) (backslash - username));
+ /* Keep full username — libsmbclient handles DOMAIN\user */
+ }
+ }
+
+ g_ptr_array_add (data->connections, conn);
+ save_connections (data->connections_file, data->connections);
+
+ return MC_PPR_OK;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+/*** public functions ****************************************************************************/
+/* --------------------------------------------------------------------------------------------- */
+
+const mc_panel_plugin_t *mc_panel_plugin_register (void);
+
+const mc_panel_plugin_t *
+mc_panel_plugin_register (void)
+{
+ return &samba_plugin;
+}
+
+/* --------------------------------------------------------------------------------------------- */
diff --git a/src/panel-plugins/sftp/Makefile.am b/src/panel-plugins/sftp/Makefile.am
new file mode 100644
index 0000000000..a36c8a048b
--- /dev/null
+++ b/src/panel-plugins/sftp/Makefile.am
@@ -0,0 +1,7 @@
+panelplugindir = $(panel_plugins_dir)
+panelplugin_LTLIBRARIES = mc-panel-sftp.la
+
+mc_panel_sftp_la_SOURCES = sftp.c
+mc_panel_sftp_la_CPPFLAGS = $(GLIB_CFLAGS) $(LIBSSH_CFLAGS) -I$(top_srcdir)
+mc_panel_sftp_la_LDFLAGS = -module -avoid-version
+mc_panel_sftp_la_LIBADD = $(GLIB_LIBS) $(LIBSSH_LIBS)
diff --git a/src/panel-plugins/sftp/sftp.c b/src/panel-plugins/sftp/sftp.c
new file mode 100644
index 0000000000..7db1d42ae6
--- /dev/null
+++ b/src/panel-plugins/sftp/sftp.c
@@ -0,0 +1,1278 @@
+/*
+ SFTP network browser panel plugin (libssh2).
+
+ Copyright (C) 2026
+ Free Software Foundation, Inc.
+
+ This file is part of the Midnight Commander.
+
+ The Midnight Commander is free software: you can redistribute it
+ and/or modify it under the terms of the GNU General Public License as
+ published by the Free Software Foundation, either version 3 of the License,
+ or (at your option) any later version.
+
+ The Midnight Commander is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+ */
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#ifdef HAVE_ARPA_INET_H
+#include
+#endif
+
+#include
+#include
+
+#include "lib/global.h"
+#include "lib/panel-plugin.h"
+#include "lib/vfs/utilvfs.h"
+#include "lib/widget.h"
+
+#include "src/filemanager/dir.h"
+
+/*** file scope type declarations ****************************************************************/
+
+typedef struct
+{
+ char *label;
+ char *host;
+ int port;
+ char *user;
+ char *path;
+ char *password;
+ char *pubkey;
+ char *privkey;
+ gboolean use_agent;
+} sftp_connection_t;
+
+typedef struct
+{
+ char *name;
+ struct stat st;
+ gboolean is_dir;
+} sftp_entry_t;
+
+typedef struct
+{
+ mc_panel_host_t *host;
+
+ gboolean at_root;
+ char *current_path;
+ GPtrArray *entries;
+
+ GPtrArray *connections;
+ char *connections_file;
+
+ sftp_connection_t *active_connection;
+
+ int socket_handle;
+ LIBSSH2_SESSION *session;
+ LIBSSH2_SFTP *sftp_session;
+
+ char *title_buf;
+} sftp_data_t;
+
+/*** forward declarations (file scope functions) *************************************************/
+
+static void *sftp_open (mc_panel_host_t *host, const char *open_path);
+static void sftp_close (void *plugin_data);
+static mc_pp_result_t sftp_get_items (void *plugin_data, void *list_ptr);
+static mc_pp_result_t sftp_chdir (void *plugin_data, const char *path);
+static mc_pp_result_t sftp_enter (void *plugin_data, const char *name, const struct stat *st);
+static mc_pp_result_t sftp_get_local_copy (void *plugin_data, const char *fname, char **local_path);
+static mc_pp_result_t sftp_delete_items (void *plugin_data, const char **names, int count);
+static const char *sftp_get_title (void *plugin_data);
+static mc_pp_result_t sftp_create_item (void *plugin_data);
+static void sftp_disconnect (sftp_data_t *data);
+
+/*** file scope variables ************************************************************************/
+
+#define SFTP_DEFAULT_PORT 22
+
+#ifndef LIBSSH2_INVALID_SOCKET
+#define LIBSSH2_INVALID_SOCKET -1
+#endif
+
+static guint sftp_libssh2_refcount = 0;
+
+static const mc_panel_plugin_t sftp_plugin = {
+ .api_version = MC_PANEL_PLUGIN_API_VERSION,
+ .name = "sftp",
+ .display_name = "SFTP network",
+ .proto = "sftp",
+ .prefix = NULL,
+ .flags =
+ MC_PPF_NAVIGATE | MC_PPF_GET_FILES | MC_PPF_DELETE | MC_PPF_CUSTOM_TITLE | MC_PPF_CREATE,
+
+ .open = sftp_open,
+ .close = sftp_close,
+ .get_items = sftp_get_items,
+
+ .chdir = sftp_chdir,
+ .enter = sftp_enter,
+ .get_local_copy = sftp_get_local_copy,
+ .delete_items = sftp_delete_items,
+ .get_title = sftp_get_title,
+ .handle_key = NULL,
+ .create_item = sftp_create_item,
+};
+
+/*** file scope functions ************************************************************************/
+
+static void
+add_entry (dir_list *list, const char *name, mode_t mode, off_t size, time_t mtime)
+{
+ struct stat st;
+
+ memset (&st, 0, sizeof (st));
+ st.st_mode = mode;
+ st.st_size = size;
+ st.st_mtime = mtime;
+ st.st_uid = getuid ();
+ st.st_gid = getgid ();
+ st.st_nlink = 1;
+
+ dir_list_append (list, name, &st, S_ISDIR (mode), FALSE);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static void
+sftp_entry_free (gpointer p)
+{
+ sftp_entry_t *e = (sftp_entry_t *) p;
+
+ g_free (e->name);
+ g_free (e);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static void
+sftp_connection_free (gpointer p)
+{
+ sftp_connection_t *c = (sftp_connection_t *) p;
+
+ g_free (c->label);
+ g_free (c->host);
+ g_free (c->user);
+ g_free (c->path);
+ g_free (c->password);
+ g_free (c->pubkey);
+ g_free (c->privkey);
+ g_free (c);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static char *
+get_connections_file_path (void)
+{
+ return g_build_filename (g_get_user_config_dir (), "mc", "sftp-connections.ini", (char *) NULL);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static GPtrArray *
+load_connections (const char *filepath)
+{
+ GPtrArray *arr;
+ GKeyFile *kf;
+ gchar **groups;
+ gsize n_groups, i;
+
+ arr = g_ptr_array_new_with_free_func (sftp_connection_free);
+
+ kf = g_key_file_new ();
+ if (!g_key_file_load_from_file (kf, filepath, G_KEY_FILE_NONE, NULL))
+ {
+ g_key_file_free (kf);
+ return arr;
+ }
+
+ groups = g_key_file_get_groups (kf, &n_groups);
+ for (i = 0; i < n_groups; i++)
+ {
+ sftp_connection_t *conn;
+ GError *error = NULL;
+
+ conn = g_new0 (sftp_connection_t, 1);
+ conn->label = g_strdup (groups[i]);
+ conn->host = g_key_file_get_string (kf, groups[i], "host", NULL);
+ conn->user = g_key_file_get_string (kf, groups[i], "user", NULL);
+ conn->path = g_key_file_get_string (kf, groups[i], "path", NULL);
+ conn->password = g_key_file_get_string (kf, groups[i], "password", NULL);
+ conn->pubkey = g_key_file_get_string (kf, groups[i], "pubkey", NULL);
+ conn->privkey = g_key_file_get_string (kf, groups[i], "privkey", NULL);
+
+ conn->port = g_key_file_get_integer (kf, groups[i], "port", &error);
+ if (error != NULL)
+ {
+ g_error_free (error);
+ conn->port = SFTP_DEFAULT_PORT;
+ }
+
+ error = NULL;
+ conn->use_agent = g_key_file_get_boolean (kf, groups[i], "use_agent", &error);
+ if (error != NULL)
+ {
+ g_error_free (error);
+ conn->use_agent = TRUE;
+ }
+
+ if (conn->host == NULL || conn->host[0] == '\0')
+ {
+ sftp_connection_free (conn);
+ continue;
+ }
+
+ if (conn->port <= 0)
+ conn->port = SFTP_DEFAULT_PORT;
+
+ if (conn->user == NULL || conn->user[0] == '\0')
+ {
+ conn->user = vfs_get_local_username ();
+ if (conn->user == NULL)
+ conn->user = g_strdup (g_get_user_name ());
+ }
+
+ if (conn->path == NULL || conn->path[0] == '\0')
+ conn->path = g_strdup ("/");
+
+ g_ptr_array_add (arr, conn);
+ }
+
+ g_strfreev (groups);
+ g_key_file_free (kf);
+ return arr;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static gboolean
+save_connections (const char *filepath, GPtrArray *connections)
+{
+ GKeyFile *kf;
+ gchar *data;
+ gsize length;
+ gboolean ok;
+ gchar *dir;
+ guint i;
+
+ kf = g_key_file_new ();
+
+ for (i = 0; i < connections->len; i++)
+ {
+ const sftp_connection_t *conn =
+ (const sftp_connection_t *) g_ptr_array_index (connections, i);
+
+ g_key_file_set_string (kf, conn->label, "host", conn->host);
+ g_key_file_set_integer (kf, conn->label, "port", conn->port > 0 ? conn->port : SFTP_DEFAULT_PORT);
+
+ if (conn->user != NULL)
+ g_key_file_set_string (kf, conn->label, "user", conn->user);
+ if (conn->path != NULL)
+ g_key_file_set_string (kf, conn->label, "path", conn->path);
+ if (conn->password != NULL)
+ g_key_file_set_string (kf, conn->label, "password", conn->password);
+ if (conn->pubkey != NULL)
+ g_key_file_set_string (kf, conn->label, "pubkey", conn->pubkey);
+ if (conn->privkey != NULL)
+ g_key_file_set_string (kf, conn->label, "privkey", conn->privkey);
+
+ g_key_file_set_boolean (kf, conn->label, "use_agent", conn->use_agent);
+ }
+
+ data = g_key_file_to_data (kf, &length, NULL);
+ g_key_file_free (kf);
+
+ if (data == NULL)
+ return FALSE;
+
+ dir = g_path_get_dirname (filepath);
+ g_mkdir_with_parents (dir, 0700);
+ g_free (dir);
+
+ ok = g_file_set_contents (filepath, data, (gssize) length, NULL);
+ g_free (data);
+
+ return ok;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static const sftp_connection_t *
+find_connection (const sftp_data_t *data, const char *label)
+{
+ guint i;
+
+ for (i = 0; i < data->connections->len; i++)
+ {
+ const sftp_connection_t *c =
+ (const sftp_connection_t *) g_ptr_array_index (data->connections, i);
+
+ if (strcmp (c->label, label) == 0)
+ return c;
+ }
+
+ return NULL;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static const sftp_entry_t *
+find_entry (const sftp_data_t *data, const char *name)
+{
+ guint i;
+
+ if (data->entries == NULL)
+ return NULL;
+
+ for (i = 0; i < data->entries->len; i++)
+ {
+ const sftp_entry_t *e = (const sftp_entry_t *) g_ptr_array_index (data->entries, i);
+
+ if (strcmp (e->name, name) == 0)
+ return e;
+ }
+
+ return NULL;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static char *
+sftp_join_path (const char *base, const char *name)
+{
+ if (strcmp (base, "/") == 0)
+ return g_strdup_printf ("/%s", name);
+
+ return g_strdup_printf ("%s/%s", base, name);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static char *
+sftp_path_up (const char *path)
+{
+ const char *last;
+
+ if (path == NULL || strcmp (path, "/") == 0)
+ return NULL;
+
+ last = strrchr (path, '/');
+ if (last == NULL || last == path)
+ return g_strdup ("/");
+
+ return g_strndup (path, (gsize) (last - path));
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static gboolean
+sftp_auth_has_method (const char *auth_list, const char *method)
+{
+ const char *p;
+ size_t method_len;
+
+ if (auth_list == NULL || method == NULL)
+ return FALSE;
+
+ p = auth_list;
+ method_len = strlen (method);
+
+ while (*p != '\0')
+ {
+ const char *end;
+ size_t token_len;
+
+ end = strchr (p, ',');
+ if (end == NULL)
+ end = p + strlen (p);
+
+ token_len = (size_t) (end - p);
+ if (token_len == method_len && strncmp (p, method, method_len) == 0)
+ return TRUE;
+
+ if (*end == '\0')
+ break;
+ p = end + 1;
+ }
+
+ return FALSE;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static int
+sftp_open_socket (const sftp_connection_t *conn)
+{
+ struct addrinfo hints, *res = NULL, *curr;
+ int sock = LIBSSH2_INVALID_SOCKET;
+ char port_buf[BUF_TINY];
+
+ if (conn->host == NULL || conn->host[0] == '\0')
+ return LIBSSH2_INVALID_SOCKET;
+
+ memset (&hints, 0, sizeof (hints));
+ hints.ai_family = AF_UNSPEC;
+ hints.ai_socktype = SOCK_STREAM;
+
+ g_snprintf (port_buf, sizeof (port_buf), "%d", conn->port > 0 ? conn->port : SFTP_DEFAULT_PORT);
+
+ if (getaddrinfo (conn->host, port_buf, &hints, &res) != 0)
+ return LIBSSH2_INVALID_SOCKET;
+
+ for (curr = res; curr != NULL; curr = curr->ai_next)
+ {
+ sock = socket (curr->ai_family, curr->ai_socktype, curr->ai_protocol);
+ if (sock < 0)
+ continue;
+
+ if (connect (sock, curr->ai_addr, curr->ai_addrlen) == 0)
+ break;
+
+ close (sock);
+ sock = LIBSSH2_INVALID_SOCKET;
+ }
+
+ freeaddrinfo (res);
+ return sock;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static gboolean
+sftp_auth_agent (sftp_data_t *data, const char *user)
+{
+ LIBSSH2_AGENT *agent;
+ struct libssh2_agent_publickey *identity = NULL;
+ int rc;
+
+ agent = libssh2_agent_init (data->session);
+ if (agent == NULL)
+ return FALSE;
+
+ rc = libssh2_agent_connect (agent);
+ if (rc != 0)
+ {
+ libssh2_agent_free (agent);
+ return FALSE;
+ }
+
+ rc = libssh2_agent_list_identities (agent);
+ if (rc != 0)
+ {
+ libssh2_agent_disconnect (agent);
+ libssh2_agent_free (agent);
+ return FALSE;
+ }
+
+ while (libssh2_agent_get_identity (agent, &identity, identity) == 0)
+ {
+ rc = libssh2_agent_userauth (agent, user, identity);
+ if (rc == 0)
+ {
+ libssh2_agent_disconnect (agent);
+ libssh2_agent_free (agent);
+ return TRUE;
+ }
+ }
+
+ libssh2_agent_disconnect (agent);
+ libssh2_agent_free (agent);
+ return FALSE;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static gboolean
+sftp_connect (sftp_data_t *data, sftp_connection_t *conn)
+{
+ const char *user;
+ const char *auth_list;
+
+ if (data == NULL || conn == NULL)
+ return FALSE;
+
+ data->socket_handle = sftp_open_socket (conn);
+ if (data->socket_handle == LIBSSH2_INVALID_SOCKET)
+ return FALSE;
+
+ data->session = libssh2_session_init ();
+ if (data->session == NULL)
+ goto fail;
+
+ libssh2_session_set_blocking (data->session, 1);
+
+ if (libssh2_session_handshake (data->session, (libssh2_socket_t) data->socket_handle) != 0)
+ goto fail;
+
+ user = (conn->user != NULL && conn->user[0] != '\0') ? conn->user : g_get_user_name ();
+ auth_list = libssh2_userauth_list (data->session, user, (unsigned int) strlen (user));
+
+ if (conn->use_agent && sftp_auth_has_method (auth_list, "publickey"))
+ {
+ if (sftp_auth_agent (data, user))
+ goto auth_ok;
+ }
+
+ if (conn->privkey != NULL && conn->privkey[0] != '\0'
+ && sftp_auth_has_method (auth_list, "publickey"))
+ {
+ if (libssh2_userauth_publickey_fromfile (data->session, user, conn->pubkey, conn->privkey,
+ conn->password) == 0)
+ goto auth_ok;
+ }
+
+ if (conn->password != NULL && conn->password[0] != '\0' && sftp_auth_has_method (auth_list, "password"))
+ {
+ if (libssh2_userauth_password (data->session, user, conn->password) == 0)
+ goto auth_ok;
+ }
+
+ if (sftp_auth_has_method (auth_list, "password"))
+ {
+ char *pwd;
+ char *prompt;
+
+ prompt = g_strdup_printf (_ ("Enter password for %s@%s"), user, conn->host);
+ pwd = input_dialog (_ ("SFTP password"), prompt, "sftp-password", INPUT_PASSWORD,
+ INPUT_COMPLETE_NONE);
+ g_free (prompt);
+
+ if (pwd != NULL && pwd[0] != '\0'
+ && libssh2_userauth_password (data->session, user, pwd) == 0)
+ {
+ g_free (conn->password);
+ conn->password = pwd;
+ goto auth_ok;
+ }
+
+ g_free (pwd);
+ }
+
+ goto fail;
+
+auth_ok:
+ data->sftp_session = libssh2_sftp_init (data->session);
+ if (data->sftp_session == NULL)
+ goto fail;
+
+ data->active_connection = conn;
+ return TRUE;
+
+fail:
+ sftp_disconnect (data);
+ return FALSE;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static void
+sftp_disconnect (sftp_data_t *data)
+{
+ if (data->sftp_session != NULL)
+ {
+ libssh2_sftp_shutdown (data->sftp_session);
+ data->sftp_session = NULL;
+ }
+
+ if (data->session != NULL)
+ {
+ libssh2_session_disconnect (data->session, "Normal Shutdown");
+ libssh2_session_free (data->session);
+ data->session = NULL;
+ }
+
+ if (data->socket_handle != LIBSSH2_INVALID_SOCKET)
+ {
+ close (data->socket_handle);
+ data->socket_handle = LIBSSH2_INVALID_SOCKET;
+ }
+
+ data->active_connection = NULL;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static void
+sftp_attr_to_stat (const LIBSSH2_SFTP_ATTRIBUTES *attrs, struct stat *st)
+{
+ memset (st, 0, sizeof (*st));
+
+ st->st_uid = getuid ();
+ st->st_gid = getgid ();
+ st->st_nlink = 1;
+
+ if ((attrs->flags & LIBSSH2_SFTP_ATTR_UIDGID) != 0)
+ {
+ st->st_uid = attrs->uid;
+ st->st_gid = attrs->gid;
+ }
+
+ if ((attrs->flags & LIBSSH2_SFTP_ATTR_SIZE) != 0)
+ st->st_size = (off_t) attrs->filesize;
+
+ if ((attrs->flags & LIBSSH2_SFTP_ATTR_ACMODTIME) != 0)
+ {
+ st->st_atime = attrs->atime;
+ st->st_mtime = attrs->mtime;
+ st->st_ctime = attrs->mtime;
+ }
+ else
+ {
+ st->st_mtime = time (NULL);
+ }
+
+ if ((attrs->flags & LIBSSH2_SFTP_ATTR_PERMISSIONS) != 0)
+ st->st_mode = attrs->permissions;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static GPtrArray *
+sftp_load_entries (sftp_data_t *data)
+{
+ GPtrArray *arr;
+ LIBSSH2_SFTP_HANDLE *dirh;
+
+ arr = g_ptr_array_new_with_free_func (sftp_entry_free);
+
+ dirh = libssh2_sftp_opendir (data->sftp_session, data->current_path);
+ if (dirh == NULL)
+ return arr;
+
+ while (TRUE)
+ {
+ char mem[BUF_MEDIUM];
+ LIBSSH2_SFTP_ATTRIBUTES attrs;
+ ssize_t rc;
+
+ rc = libssh2_sftp_readdir_ex (dirh, mem, sizeof (mem), NULL, 0, &attrs);
+ if (rc <= 0)
+ break;
+
+ if ((size_t) rc >= sizeof (mem))
+ rc = (ssize_t) sizeof (mem) - 1;
+ mem[rc] = '\0';
+
+ if (strcmp (mem, ".") == 0 || strcmp (mem, "..") == 0)
+ continue;
+
+ {
+ sftp_entry_t *entry;
+ mode_t mode;
+
+ entry = g_new0 (sftp_entry_t, 1);
+ entry->name = g_strdup (mem);
+ sftp_attr_to_stat (&attrs, &entry->st);
+
+ mode = entry->st.st_mode;
+ if (!S_ISDIR (mode) && !S_ISREG (mode) && !S_ISLNK (mode))
+ {
+ mode = S_IFREG | 0644;
+ entry->st.st_mode = mode;
+ }
+
+ entry->is_dir = S_ISDIR (entry->st.st_mode);
+
+ g_ptr_array_add (arr, entry);
+ }
+ }
+
+ libssh2_sftp_closedir (dirh);
+ return arr;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static gboolean
+sftp_reload_entries (sftp_data_t *data)
+{
+ if (data->entries != NULL)
+ {
+ g_ptr_array_free (data->entries, TRUE);
+ data->entries = NULL;
+ }
+
+ if (data->at_root)
+ return TRUE;
+
+ if (data->sftp_session == NULL)
+ return FALSE;
+
+ data->entries = sftp_load_entries (data);
+ return TRUE;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static gboolean
+show_connection_dialog (char **label, char **host, char **port, char **user, char **path,
+ char **password, char **pubkey, char **privkey, gboolean *use_agent)
+{
+ /* clang-format off */
+ quick_widget_t quick_widgets[] = {
+ QUICK_LABELED_INPUT (N_("Name:"), input_label_above,
+ *label != NULL ? *label : "", "sftp-conn-label",
+ label, NULL, FALSE, FALSE, INPUT_COMPLETE_NONE),
+ QUICK_SEPARATOR (FALSE),
+ QUICK_LABELED_INPUT (N_("Host:"), input_label_above,
+ *host != NULL ? *host : "", "sftp-conn-host",
+ host, NULL, FALSE, FALSE, INPUT_COMPLETE_HOSTNAMES),
+ QUICK_SEPARATOR (FALSE),
+ QUICK_LABELED_INPUT (N_("Port:"), input_label_above,
+ *port != NULL ? *port : "22", "sftp-conn-port",
+ port, NULL, FALSE, FALSE, INPUT_COMPLETE_NONE),
+ QUICK_SEPARATOR (FALSE),
+ QUICK_LABELED_INPUT (N_("User:"), input_label_above,
+ *user != NULL ? *user : "", "sftp-conn-user",
+ user, NULL, FALSE, FALSE, INPUT_COMPLETE_NONE),
+ QUICK_SEPARATOR (FALSE),
+ QUICK_LABELED_INPUT (N_("Remote path:"), input_label_above,
+ *path != NULL ? *path : "/", "sftp-conn-path",
+ path, NULL, FALSE, FALSE, INPUT_COMPLETE_NONE),
+ QUICK_SEPARATOR (FALSE),
+ QUICK_LABELED_INPUT (N_("Password:"), input_label_above,
+ *password != NULL ? *password : "", "sftp-conn-pass",
+ password, NULL, TRUE, TRUE, INPUT_COMPLETE_NONE),
+ QUICK_SEPARATOR (FALSE),
+ QUICK_LABELED_INPUT (N_("Public key file:"), input_label_above,
+ *pubkey != NULL ? *pubkey : "", "sftp-conn-pubkey",
+ pubkey, NULL, FALSE, FALSE, INPUT_COMPLETE_FILENAMES),
+ QUICK_SEPARATOR (FALSE),
+ QUICK_LABELED_INPUT (N_("Private key file:"), input_label_above,
+ *privkey != NULL ? *privkey : "", "sftp-conn-privkey",
+ privkey, NULL, FALSE, FALSE, INPUT_COMPLETE_FILENAMES),
+ QUICK_SEPARATOR (FALSE),
+ QUICK_CHECKBOX (N_("Use SSH &agent"), use_agent, NULL),
+ QUICK_BUTTONS_OK_CANCEL,
+ QUICK_END,
+ };
+ /* clang-format on */
+
+ WRect r = { -1, -1, 0, 56 };
+
+ quick_dialog_t qdlg = {
+ .rect = r,
+ .title = N_ ("SFTP Connection"),
+ .help = "[SFTP Plugin]",
+ .widgets = quick_widgets,
+ .callback = NULL,
+ .mouse_callback = NULL,
+ };
+
+ return (quick_dialog (&qdlg) == B_ENTER);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static gboolean
+sftp_activate_connection (sftp_data_t *data, sftp_connection_t *conn)
+{
+ char *path;
+
+ sftp_disconnect (data);
+
+ if (!sftp_connect (data, conn))
+ return FALSE;
+
+ path = (conn->path != NULL && conn->path[0] != '\0') ? g_strdup (conn->path) : g_strdup ("/");
+ if (path[0] != '/')
+ {
+ char *tmp = g_strdup_printf ("/%s", path);
+ g_free (path);
+ path = tmp;
+ }
+
+ g_free (data->current_path);
+ data->current_path = path;
+ data->at_root = FALSE;
+
+ sftp_reload_entries (data);
+ return TRUE;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+/* Plugin callbacks */
+/* --------------------------------------------------------------------------------------------- */
+
+static void *
+sftp_open (mc_panel_host_t *host, const char *open_path)
+{
+ sftp_data_t *data;
+
+ (void) open_path;
+
+ if (sftp_libssh2_refcount == 0)
+ {
+ if (libssh2_init (0) != 0)
+ return NULL;
+ }
+ sftp_libssh2_refcount++;
+
+ data = g_new0 (sftp_data_t, 1);
+ data->host = host;
+ data->at_root = TRUE;
+ data->current_path = NULL;
+ data->entries = NULL;
+ data->title_buf = NULL;
+
+ data->socket_handle = LIBSSH2_INVALID_SOCKET;
+ data->session = NULL;
+ data->sftp_session = NULL;
+ data->active_connection = NULL;
+
+ data->connections_file = get_connections_file_path ();
+ data->connections = load_connections (data->connections_file);
+
+ return data;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static void
+sftp_close (void *plugin_data)
+{
+ sftp_data_t *data = (sftp_data_t *) plugin_data;
+
+ sftp_disconnect (data);
+
+ if (data->entries != NULL)
+ g_ptr_array_free (data->entries, TRUE);
+
+ g_ptr_array_free (data->connections, TRUE);
+
+ g_free (data->current_path);
+ g_free (data->title_buf);
+ g_free (data->connections_file);
+ g_free (data);
+
+ if (sftp_libssh2_refcount > 0)
+ sftp_libssh2_refcount--;
+
+ if (sftp_libssh2_refcount == 0)
+ libssh2_exit ();
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static mc_pp_result_t
+sftp_get_items (void *plugin_data, void *list_ptr)
+{
+ dir_list *list = (dir_list *) list_ptr;
+ sftp_data_t *data = (sftp_data_t *) plugin_data;
+ guint i;
+
+ if (data->at_root)
+ {
+ for (i = 0; i < data->connections->len; i++)
+ {
+ const sftp_connection_t *conn =
+ (const sftp_connection_t *) g_ptr_array_index (data->connections, i);
+
+ add_entry (list, conn->label, S_IFDIR | 0755, 0, time (NULL));
+ }
+ return MC_PPR_OK;
+ }
+
+ if (data->entries != NULL)
+ {
+ for (i = 0; i < data->entries->len; i++)
+ {
+ const sftp_entry_t *e = (const sftp_entry_t *) g_ptr_array_index (data->entries, i);
+ mode_t mode;
+ off_t size;
+
+ mode = e->st.st_mode;
+ if (!S_ISDIR (mode) && !S_ISREG (mode) && !S_ISLNK (mode))
+ mode = S_IFREG | 0644;
+
+ size = S_ISDIR (mode) ? 0 : e->st.st_size;
+ add_entry (list, e->name, mode, size, e->st.st_mtime != 0 ? e->st.st_mtime : time (NULL));
+ }
+ }
+
+ return MC_PPR_OK;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static mc_pp_result_t
+sftp_chdir (void *plugin_data, const char *path)
+{
+ sftp_data_t *data = (sftp_data_t *) plugin_data;
+
+ if (strcmp (path, "..") == 0)
+ {
+ if (data->at_root)
+ return MC_PPR_NOT_SUPPORTED;
+
+ if (data->current_path != NULL && strcmp (data->current_path, "/") == 0)
+ {
+ data->at_root = TRUE;
+ sftp_disconnect (data);
+
+ if (data->entries != NULL)
+ {
+ g_ptr_array_free (data->entries, TRUE);
+ data->entries = NULL;
+ }
+
+ g_free (data->current_path);
+ data->current_path = NULL;
+ return MC_PPR_OK;
+ }
+
+ {
+ char *parent = sftp_path_up (data->current_path);
+
+ if (parent == NULL)
+ {
+ data->at_root = TRUE;
+ sftp_disconnect (data);
+
+ if (data->entries != NULL)
+ {
+ g_ptr_array_free (data->entries, TRUE);
+ data->entries = NULL;
+ }
+
+ g_free (data->current_path);
+ data->current_path = NULL;
+ return MC_PPR_OK;
+ }
+
+ g_free (data->current_path);
+ data->current_path = parent;
+ sftp_reload_entries (data);
+ return MC_PPR_OK;
+ }
+ }
+
+ if (data->at_root)
+ {
+ sftp_connection_t *conn = (sftp_connection_t *) find_connection (data, path);
+
+ if (conn == NULL)
+ return MC_PPR_FAILED;
+
+ return sftp_activate_connection (data, conn) ? MC_PPR_OK : MC_PPR_FAILED;
+ }
+
+ {
+ const sftp_entry_t *entry;
+ char *new_path;
+
+ entry = find_entry (data, path);
+ if (entry == NULL || !entry->is_dir)
+ return MC_PPR_FAILED;
+
+ new_path = sftp_join_path (data->current_path, path);
+
+ g_free (data->current_path);
+ data->current_path = new_path;
+
+ sftp_reload_entries (data);
+ return MC_PPR_OK;
+ }
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static mc_pp_result_t
+sftp_enter (void *plugin_data, const char *name, const struct stat *st)
+{
+ sftp_data_t *data = (sftp_data_t *) plugin_data;
+
+ (void) st;
+
+ if (data->at_root)
+ {
+ sftp_connection_t *conn = (sftp_connection_t *) find_connection (data, name);
+
+ if (conn == NULL)
+ return MC_PPR_FAILED;
+
+ return sftp_activate_connection (data, conn) ? MC_PPR_OK : MC_PPR_FAILED;
+ }
+
+ {
+ const sftp_entry_t *entry;
+
+ entry = find_entry (data, name);
+ if (entry == NULL)
+ return MC_PPR_FAILED;
+
+ if (entry->is_dir)
+ {
+ char *new_path;
+
+ new_path = sftp_join_path (data->current_path, name);
+ g_free (data->current_path);
+ data->current_path = new_path;
+
+ sftp_reload_entries (data);
+ return MC_PPR_OK;
+ }
+ }
+
+ return MC_PPR_NOT_SUPPORTED;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static mc_pp_result_t
+sftp_get_local_copy (void *plugin_data, const char *fname, char **local_path)
+{
+ sftp_data_t *data = (sftp_data_t *) plugin_data;
+ LIBSSH2_SFTP_HANDLE *fileh;
+ char *remote_path;
+ int local_fd;
+ GError *error = NULL;
+
+ if (data->at_root || data->sftp_session == NULL || data->current_path == NULL)
+ return MC_PPR_FAILED;
+
+ remote_path = sftp_join_path (data->current_path, fname);
+ fileh = libssh2_sftp_open (data->sftp_session, remote_path, LIBSSH2_FXF_READ, 0);
+ g_free (remote_path);
+
+ if (fileh == NULL)
+ return MC_PPR_FAILED;
+
+ local_fd = g_file_open_tmp ("mc-pp-sftp-XXXXXX", local_path, &error);
+ if (local_fd == -1)
+ {
+ if (error != NULL)
+ g_error_free (error);
+ libssh2_sftp_close (fileh);
+ return MC_PPR_FAILED;
+ }
+
+ while (TRUE)
+ {
+ char buf[8192];
+ ssize_t n;
+
+ n = libssh2_sftp_read (fileh, buf, sizeof (buf));
+ if (n == 0)
+ break;
+
+ if (n < 0 || write (local_fd, buf, (size_t) n) != n)
+ {
+ close (local_fd);
+ libssh2_sftp_close (fileh);
+ unlink (*local_path);
+ g_free (*local_path);
+ *local_path = NULL;
+ return MC_PPR_FAILED;
+ }
+ }
+
+ close (local_fd);
+ libssh2_sftp_close (fileh);
+
+ return MC_PPR_OK;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static mc_pp_result_t
+sftp_delete_items (void *plugin_data, const char **names, int count)
+{
+ sftp_data_t *data = (sftp_data_t *) plugin_data;
+ int i;
+ gboolean failed = FALSE;
+
+ if (data->at_root)
+ {
+ for (i = 0; i < count; i++)
+ {
+ guint j;
+
+ for (j = 0; j < data->connections->len; j++)
+ {
+ const sftp_connection_t *conn =
+ (const sftp_connection_t *) g_ptr_array_index (data->connections, j);
+
+ if (strcmp (conn->label, names[i]) == 0)
+ {
+ g_ptr_array_remove_index (data->connections, j);
+ break;
+ }
+ }
+ }
+
+ save_connections (data->connections_file, data->connections);
+ return MC_PPR_OK;
+ }
+
+ if (data->sftp_session == NULL)
+ return MC_PPR_FAILED;
+
+ for (i = 0; i < count; i++)
+ {
+ const sftp_entry_t *entry;
+ char *remote_path;
+ int rc;
+
+ entry = find_entry (data, names[i]);
+ if (entry == NULL)
+ continue;
+
+ remote_path = sftp_join_path (data->current_path, names[i]);
+
+ if (entry->is_dir)
+ rc = libssh2_sftp_rmdir_ex (data->sftp_session, remote_path, (unsigned int) strlen (remote_path));
+ else
+ rc = libssh2_sftp_unlink_ex (data->sftp_session, remote_path,
+ (unsigned int) strlen (remote_path));
+
+ if (rc != 0)
+ failed = TRUE;
+
+ g_free (remote_path);
+ }
+
+ sftp_reload_entries (data);
+
+ return failed ? MC_PPR_FAILED : MC_PPR_OK;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static const char *
+sftp_get_title (void *plugin_data)
+{
+ sftp_data_t *data = (sftp_data_t *) plugin_data;
+
+ g_free (data->title_buf);
+
+ if (data->at_root || data->active_connection == NULL)
+ {
+ data->title_buf = g_strdup ("/");
+ return data->title_buf;
+ }
+
+ data->title_buf = g_strdup_printf ("%s:%s", data->active_connection->host,
+ data->current_path != NULL ? data->current_path : "/");
+
+ return data->title_buf;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static mc_pp_result_t
+sftp_create_item (void *plugin_data)
+{
+ sftp_data_t *data = (sftp_data_t *) plugin_data;
+ sftp_connection_t *conn;
+
+ char *label = NULL;
+ char *host = NULL;
+ char *port = g_strdup ("22");
+ char *user = vfs_get_local_username ();
+ char *path = g_strdup ("/");
+ char *password = NULL;
+ char *pubkey = NULL;
+ char *privkey = NULL;
+ gboolean use_agent = TRUE;
+
+ if (user == NULL)
+ user = g_strdup (g_get_user_name ());
+
+ if (!data->at_root)
+ return MC_PPR_NOT_SUPPORTED;
+
+ if (!show_connection_dialog (&label, &host, &port, &user, &path, &password, &pubkey, &privkey,
+ &use_agent))
+ {
+ g_free (label);
+ g_free (host);
+ g_free (port);
+ g_free (user);
+ g_free (path);
+ g_free (password);
+ g_free (pubkey);
+ g_free (privkey);
+ return MC_PPR_FAILED;
+ }
+
+ if (label == NULL || label[0] == '\0' || host == NULL || host[0] == '\0')
+ {
+ g_free (label);
+ g_free (host);
+ g_free (port);
+ g_free (user);
+ g_free (path);
+ g_free (password);
+ g_free (pubkey);
+ g_free (privkey);
+ return MC_PPR_FAILED;
+ }
+
+ conn = g_new0 (sftp_connection_t, 1);
+ conn->label = label;
+ conn->host = host;
+ conn->port = (port != NULL && port[0] != '\0') ? atoi (port) : SFTP_DEFAULT_PORT;
+ if (conn->port <= 0)
+ conn->port = SFTP_DEFAULT_PORT;
+
+ conn->user = user;
+ conn->path = path;
+
+ conn->password = (password != NULL && password[0] != '\0') ? password : NULL;
+ if (conn->password == NULL)
+ g_free (password);
+
+ conn->pubkey = (pubkey != NULL && pubkey[0] != '\0') ? pubkey : NULL;
+ if (conn->pubkey == NULL)
+ g_free (pubkey);
+
+ conn->privkey = (privkey != NULL && privkey[0] != '\0') ? privkey : NULL;
+ if (conn->privkey == NULL)
+ g_free (privkey);
+
+ conn->use_agent = use_agent;
+
+ g_free (port);
+
+ g_ptr_array_add (data->connections, conn);
+ save_connections (data->connections_file, data->connections);
+
+ return MC_PPR_OK;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+/*** public functions ****************************************************************************/
+/* --------------------------------------------------------------------------------------------- */
+
+const mc_panel_plugin_t *mc_panel_plugin_register (void);
+
+const mc_panel_plugin_t *
+mc_panel_plugin_register (void)
+{
+ return &sftp_plugin;
+}
+
+/* --------------------------------------------------------------------------------------------- */
diff --git a/src/panel-plugins/systemd/Makefile.am b/src/panel-plugins/systemd/Makefile.am
new file mode 100644
index 0000000000..b1c23f6153
--- /dev/null
+++ b/src/panel-plugins/systemd/Makefile.am
@@ -0,0 +1,7 @@
+panelplugindir = $(panel_plugins_dir)
+panelplugin_LTLIBRARIES = mc-panel-systemd.la
+
+mc_panel_systemd_la_SOURCES = systemd.c
+mc_panel_systemd_la_CPPFLAGS = $(GLIB_CFLAGS) -I$(top_srcdir)
+mc_panel_systemd_la_LDFLAGS = -module -avoid-version
+mc_panel_systemd_la_LIBADD = $(GLIB_LIBS)
diff --git a/src/panel-plugins/systemd/systemd.c b/src/panel-plugins/systemd/systemd.c
new file mode 100644
index 0000000000..523c46f199
--- /dev/null
+++ b/src/panel-plugins/systemd/systemd.c
@@ -0,0 +1,651 @@
+/*
+ Systemd unit browser panel plugin.
+
+ Copyright (C) 2025
+ Free Software Foundation, Inc.
+
+ Written by:
+ Ilia Maslakov , 2026.
+
+ This file is part of the Midnight Commander.
+
+ The Midnight Commander is free software: you can redistribute it
+ and/or modify it under the terms of the GNU General Public License as
+ published by the Free Software Foundation, either version 3 of the License,
+ or (at your option) any later version.
+
+ The Midnight Commander is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+ */
+
+#include
+
+#include
+#include
+#include
+#include
+
+#include "lib/global.h"
+#include "lib/panel-plugin.h"
+
+#include "src/filemanager/dir.h"
+
+/*** file scope type declarations ****************************************************************/
+
+typedef enum
+{
+ UNIT_STATE_ACTIVE,
+ UNIT_STATE_INACTIVE,
+ UNIT_STATE_FAILED
+} unit_state_t;
+
+typedef struct
+{
+ char *name;
+ unit_state_t state;
+} systemd_unit_t;
+
+typedef struct
+{
+ mc_panel_host_t *host;
+ char *current_type; /* NULL = root, "services" = inside type dir */
+ GPtrArray *units; /* array of systemd_unit_t* */
+ char *title_buf; /* owned buffer for get_title() return */
+} systemd_data_t;
+
+/*** file scope variables ************************************************************************/
+
+static const struct
+{
+ const char *dir_name;
+ const char *type_arg;
+} unit_type_dirs[] = {
+ { "services", "service" },
+ { "timers", "timer" },
+ { "sockets", "socket" },
+ { "targets", "target" },
+ { "mounts", "mount" },
+ { "slices", "slice" },
+ { "paths", "path" },
+ { "scopes", "scope" },
+ { "automounts", "automount" },
+ { "swaps", "swap" },
+};
+
+static const size_t unit_type_dirs_count = G_N_ELEMENTS (unit_type_dirs);
+
+/*** forward declarations (file scope functions) *************************************************/
+
+static void *systemd_open (mc_panel_host_t *host, const char *open_path);
+static void systemd_close (void *plugin_data);
+static mc_pp_result_t systemd_get_items (void *plugin_data, void *list_ptr);
+static mc_pp_result_t systemd_chdir (void *plugin_data, const char *path);
+static mc_pp_result_t systemd_enter (void *plugin_data, const char *name, const struct stat *st);
+static mc_pp_result_t systemd_get_local_copy (void *plugin_data, const char *fname,
+ char **local_path);
+static mc_pp_result_t systemd_delete_items (void *plugin_data, const char **names, int count);
+static const char *systemd_get_title (void *plugin_data);
+
+static const mc_panel_plugin_t systemd_plugin = {
+ .api_version = MC_PANEL_PLUGIN_API_VERSION,
+ .name = "systemd",
+ .display_name = "Systemd units",
+ .proto = "systemd",
+ .prefix = NULL,
+ .flags = MC_PPF_NAVIGATE | MC_PPF_GET_FILES | MC_PPF_DELETE | MC_PPF_CUSTOM_TITLE,
+
+ .open = systemd_open,
+ .close = systemd_close,
+ .get_items = systemd_get_items,
+
+ .chdir = systemd_chdir,
+ .enter = systemd_enter,
+ .get_local_copy = systemd_get_local_copy,
+ .delete_items = systemd_delete_items,
+ .get_title = systemd_get_title,
+ .handle_key = NULL,
+};
+
+/*** file scope functions ************************************************************************/
+
+static void
+add_entry (dir_list *list, const char *name, mode_t mode, off_t size)
+{
+ struct stat st;
+
+ memset (&st, 0, sizeof (st));
+ st.st_mode = mode;
+ st.st_size = size;
+ st.st_mtime = time (NULL);
+ st.st_uid = getuid ();
+ st.st_gid = getgid ();
+ st.st_nlink = 1;
+
+ dir_list_append (list, name, &st, S_ISDIR (mode), FALSE);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static mode_t
+state_to_mode (unit_state_t state)
+{
+ switch (state)
+ {
+ case UNIT_STATE_ACTIVE:
+ return S_IFREG | 0755;
+ case UNIT_STATE_FAILED:
+ return S_IFREG | 0000;
+ case UNIT_STATE_INACTIVE:
+ default:
+ return S_IFREG | 0644;
+ }
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static const char *
+state_to_prefix (unit_state_t state)
+{
+ switch (state)
+ {
+ case UNIT_STATE_ACTIVE:
+ return "[ACTIVE] ";
+ case UNIT_STATE_FAILED:
+ return "[FAILED] ";
+ case UNIT_STATE_INACTIVE:
+ default:
+ return "[INACTIVE] ";
+ }
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+/* Strip "[STATE] " prefix from display name to get real unit name */
+static const char *
+strip_state_prefix (const char *display_name)
+{
+ const char *p;
+
+ if (display_name[0] != '[')
+ return display_name;
+
+ p = strchr (display_name, ']');
+ if (p == NULL)
+ return display_name;
+
+ p++;
+ while (*p == ' ')
+ p++;
+
+ return p;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static gboolean
+systemd_run_cmd (const char *cmd, char **output)
+{
+ gchar *std_out = NULL;
+ gchar *std_err = NULL;
+ gint exit_status = 0;
+ GError *error = NULL;
+ gboolean ok;
+
+ ok = g_spawn_command_line_sync (cmd, &std_out, &std_err, &exit_status, &error);
+
+ g_free (std_err);
+
+ if (!ok)
+ {
+ if (error != NULL)
+ g_error_free (error);
+ g_free (std_out);
+ if (output != NULL)
+ *output = NULL;
+ return FALSE;
+ }
+
+ if (output != NULL)
+ *output = std_out;
+ else
+ g_free (std_out);
+
+ return (exit_status == 0);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static void
+systemd_unit_free (gpointer p)
+{
+ systemd_unit_t *u = (systemd_unit_t *) p;
+
+ g_free (u->name);
+ g_free (u);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static GPtrArray *
+systemd_load_units (const char *type_arg)
+{
+ GPtrArray *arr;
+ char *cmd;
+ char *output = NULL;
+ char **lines;
+ int i;
+
+ arr = g_ptr_array_new_with_free_func (systemd_unit_free);
+
+ cmd = g_strdup_printf ("systemctl list-units --type=%s --all --no-legend --no-pager", type_arg);
+
+ if (!systemd_run_cmd (cmd, &output) || output == NULL)
+ {
+ g_free (cmd);
+ g_free (output);
+ return arr;
+ }
+ g_free (cmd);
+
+ lines = g_strsplit (output, "\n", -1);
+ g_free (output);
+
+ for (i = 0; lines[i] != NULL; i++)
+ {
+ systemd_unit_t *unit;
+ char *line;
+ char **tokens;
+ int t;
+ /* parsed fields */
+ const char *unit_name = NULL;
+ const char *active_state = NULL;
+ int field_idx;
+
+ line = g_strstrip (lines[i]);
+ if (line[0] == '\0')
+ continue;
+
+ /* skip leading bullet character (● or *) */
+ if ((guchar) line[0] > 127 || line[0] == '*')
+ {
+ /* ● is a multi-byte UTF-8 sequence; skip it */
+ if ((guchar) line[0] >= 0xC0)
+ {
+ /* skip full UTF-8 character */
+ if ((guchar) line[0] >= 0xF0)
+ line += 4;
+ else if ((guchar) line[0] >= 0xE0)
+ line += 3;
+ else
+ line += 2;
+ }
+ else
+ line++;
+
+ line = g_strstrip (line);
+ }
+
+ /* tokenize: unit_name load_state active_state sub_state description... */
+ tokens = g_strsplit_set (line, " \t", -1);
+
+ /* collect non-empty tokens */
+ field_idx = 0;
+ for (t = 0; tokens[t] != NULL && field_idx < 3; t++)
+ {
+ if (tokens[t][0] == '\0')
+ continue;
+
+ switch (field_idx)
+ {
+ case 0:
+ unit_name = tokens[t];
+ break;
+ case 2:
+ active_state = tokens[t];
+ break;
+ default:
+ break;
+ }
+ field_idx++;
+ }
+
+ if (unit_name == NULL || active_state == NULL)
+ {
+ g_strfreev (tokens);
+ continue;
+ }
+
+ unit = g_new (systemd_unit_t, 1);
+ unit->name = g_strdup (unit_name);
+
+ if (strcmp (active_state, "active") == 0)
+ unit->state = UNIT_STATE_ACTIVE;
+ else if (strcmp (active_state, "failed") == 0)
+ unit->state = UNIT_STATE_FAILED;
+ else
+ unit->state = UNIT_STATE_INACTIVE;
+
+ g_ptr_array_add (arr, unit);
+ g_strfreev (tokens);
+ }
+
+ g_strfreev (lines);
+ return arr;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static const char *
+find_type_arg (const char *dir_name)
+{
+ size_t i;
+
+ for (i = 0; i < unit_type_dirs_count; i++)
+ if (strcmp (unit_type_dirs[i].dir_name, dir_name) == 0)
+ return unit_type_dirs[i].type_arg;
+
+ return NULL;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static const systemd_unit_t *
+find_unit (const systemd_data_t *data, const char *display_name)
+{
+ guint i;
+ const char *real_name;
+
+ if (data->units == NULL)
+ return NULL;
+
+ real_name = strip_state_prefix (display_name);
+
+ for (i = 0; i < data->units->len; i++)
+ {
+ const systemd_unit_t *u = (const systemd_unit_t *) g_ptr_array_index (data->units, i);
+
+ if (strcmp (u->name, real_name) == 0)
+ return u;
+ }
+
+ return NULL;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static void
+reload_units (systemd_data_t *data)
+{
+ const char *type_arg;
+
+ if (data->current_type == NULL)
+ return;
+
+ type_arg = find_type_arg (data->current_type);
+ if (type_arg == NULL)
+ return;
+
+ if (data->units != NULL)
+ g_ptr_array_free (data->units, TRUE);
+
+ data->units = systemd_load_units (type_arg);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static void *
+systemd_open (mc_panel_host_t *host, const char *open_path)
+{
+ systemd_data_t *data;
+
+ (void) open_path;
+
+ data = g_new0 (systemd_data_t, 1);
+ data->host = host;
+ data->current_type = NULL;
+ data->units = NULL;
+ data->title_buf = NULL;
+
+ return data;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static void
+systemd_close (void *plugin_data)
+{
+ systemd_data_t *data = (systemd_data_t *) plugin_data;
+
+ g_free (data->current_type);
+ g_free (data->title_buf);
+
+ if (data->units != NULL)
+ g_ptr_array_free (data->units, TRUE);
+
+ g_free (data);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static mc_pp_result_t
+systemd_get_items (void *plugin_data, void *list_ptr)
+{
+ dir_list *list = (dir_list *) list_ptr;
+ systemd_data_t *data = (systemd_data_t *) plugin_data;
+
+ if (data->current_type == NULL)
+ {
+ /* root view: show type directories */
+ size_t i;
+
+ for (i = 0; i < unit_type_dirs_count; i++)
+ add_entry (list, unit_type_dirs[i].dir_name, S_IFDIR | 0755, 4096);
+ }
+ else
+ {
+ /* inside a type dir: show units */
+ guint i;
+
+ if (data->units != NULL)
+ {
+ for (i = 0; i < data->units->len; i++)
+ {
+ const systemd_unit_t *u =
+ (const systemd_unit_t *) g_ptr_array_index (data->units, i);
+ char *display_name;
+
+ display_name =
+ g_strdup_printf ("%s%s", state_to_prefix (u->state), u->name);
+ add_entry (list, display_name, state_to_mode (u->state), 0);
+ g_free (display_name);
+ }
+ }
+ }
+
+ return MC_PPR_OK;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static mc_pp_result_t
+systemd_chdir (void *plugin_data, const char *path)
+{
+ systemd_data_t *data = (systemd_data_t *) plugin_data;
+
+ if (strcmp (path, "..") == 0)
+ {
+ if (data->current_type != NULL)
+ {
+ /* go back to root */
+ g_free (data->current_type);
+ data->current_type = NULL;
+
+ if (data->units != NULL)
+ {
+ g_ptr_array_free (data->units, TRUE);
+ data->units = NULL;
+ }
+
+ return MC_PPR_OK;
+ }
+
+ /* already at root — close plugin */
+ return MC_PPR_NOT_SUPPORTED;
+ }
+
+ /* entering a type directory */
+ if (data->current_type == NULL)
+ {
+ const char *type_arg;
+
+ type_arg = find_type_arg (path);
+ if (type_arg == NULL)
+ return MC_PPR_FAILED;
+
+ data->current_type = g_strdup (path);
+ data->units = systemd_load_units (type_arg);
+
+ return MC_PPR_OK;
+ }
+
+ return MC_PPR_FAILED;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static mc_pp_result_t
+systemd_enter (void *plugin_data, const char *name, const struct stat *st)
+{
+ systemd_data_t *data = (systemd_data_t *) plugin_data;
+ const systemd_unit_t *u;
+ char *cmd;
+
+ (void) st;
+
+ if (data->current_type == NULL)
+ return MC_PPR_NOT_SUPPORTED; /* directories handled by chdir */
+
+ u = find_unit (data, name);
+ if (u == NULL)
+ return MC_PPR_FAILED;
+
+ if (u->state == UNIT_STATE_ACTIVE)
+ cmd = g_strdup_printf ("systemctl restart %s", u->name);
+ else
+ cmd = g_strdup_printf ("systemctl start %s", u->name);
+
+ systemd_run_cmd (cmd, NULL);
+ g_free (cmd);
+
+ reload_units (data);
+
+ return MC_PPR_OK;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static mc_pp_result_t
+systemd_get_local_copy (void *plugin_data, const char *fname, char **local_path)
+{
+ systemd_data_t *data = (systemd_data_t *) plugin_data;
+ char *cmd;
+ char *output = NULL;
+ GError *error = NULL;
+ int fd;
+
+ if (data->current_type == NULL)
+ return MC_PPR_FAILED;
+
+ cmd = g_strdup_printf ("systemctl status %s --no-pager", strip_state_prefix (fname));
+ systemd_run_cmd (cmd, &output);
+ g_free (cmd);
+
+ if (output == NULL)
+ output = g_strdup ("(no status output)\n");
+
+ fd = g_file_open_tmp ("mc-pp-systemd-XXXXXX", local_path, &error);
+ if (fd == -1)
+ {
+ if (error != NULL)
+ g_error_free (error);
+ g_free (output);
+ return MC_PPR_FAILED;
+ }
+
+ if (write (fd, output, strlen (output)) == -1)
+ {
+ close (fd);
+ unlink (*local_path);
+ g_free (*local_path);
+ *local_path = NULL;
+ g_free (output);
+ return MC_PPR_FAILED;
+ }
+
+ close (fd);
+ g_free (output);
+
+ return MC_PPR_OK;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static mc_pp_result_t
+systemd_delete_items (void *plugin_data, const char **names, int count)
+{
+ systemd_data_t *data = (systemd_data_t *) plugin_data;
+ int i;
+
+ if (data->current_type == NULL)
+ return MC_PPR_FAILED;
+
+ for (i = 0; i < count; i++)
+ {
+ char *cmd;
+
+ cmd = g_strdup_printf ("systemctl stop %s", strip_state_prefix (names[i]));
+ systemd_run_cmd (cmd, NULL);
+ g_free (cmd);
+ }
+
+ reload_units (data);
+
+ return MC_PPR_OK;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+static const char *
+systemd_get_title (void *plugin_data)
+{
+ systemd_data_t *data = (systemd_data_t *) plugin_data;
+
+ g_free (data->title_buf);
+
+ if (data->current_type != NULL)
+ data->title_buf = g_strdup_printf ("/%s", data->current_type);
+ else
+ data->title_buf = g_strdup ("/");
+
+ return data->title_buf;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+/*** public functions ****************************************************************************/
+/* --------------------------------------------------------------------------------------------- */
+
+/* Entry point called by the panel plugin loader */
+const mc_panel_plugin_t *mc_panel_plugin_register (void);
+
+const mc_panel_plugin_t *
+mc_panel_plugin_register (void)
+{
+ return &systemd_plugin;
+}
+
+/* --------------------------------------------------------------------------------------------- */
diff --git a/src/textconf.c b/src/textconf.c
index 33659d3b3d..3f35110bfd 100644
--- a/src/textconf.c
+++ b/src/textconf.c
@@ -31,10 +31,6 @@
#include
#include // uintmax_t
-#if defined(ENABLE_VFS) && defined(ENABLE_VFS_SFTP)
-#include
-#endif
-
#include "lib/global.h"
#include "lib/fileloc.h"
#include "lib/mcconfig.h"
@@ -165,11 +161,6 @@ show_version (void)
#error "Cannot compile mc without S-Lang or ncurses"
#endif
-#if defined(ENABLE_VFS) && defined(ENABLE_VFS_SFTP)
- printf (_ ("Built with libssh2 %d.%d.%d\n"), LIBSSH2_VERSION_MAJOR, LIBSSH2_VERSION_MINOR,
- LIBSSH2_VERSION_PATCH);
-#endif
-
for (i = 0; features[i] != NULL; i++)
puts (_ (features[i]));
diff --git a/src/vfs/Makefile.am b/src/vfs/Makefile.am
index 485eb0b368..ad7803067c 100644
--- a/src/vfs/Makefile.am
+++ b/src/vfs/Makefile.am
@@ -28,7 +28,6 @@ endif
if ENABLE_VFS_SFTP
SUBDIRS += sftpfs
-libmc_vfs_la_LIBADD += sftpfs/libvfs-sftpfs.la
endif
if ENABLE_VFS_SFS
diff --git a/src/vfs/plugins_init.c b/src/vfs/plugins_init.c
index 4534dc666e..4567ec6c0b 100644
--- a/src/vfs/plugins_init.c
+++ b/src/vfs/plugins_init.c
@@ -54,10 +54,6 @@
#include "ftpfs/ftpfs.h"
#endif
-#ifdef ENABLE_VFS_SFTP
-#include "sftpfs/sftpfs.h"
-#endif
-
#ifdef ENABLE_VFS_SFS
#include "sfs/sfs.h"
#endif
@@ -105,12 +101,19 @@ vfs_plugins_init (void)
#ifdef ENABLE_VFS_FTP
vfs_init_ftpfs ();
#endif
-#ifdef ENABLE_VFS_SFTP
- vfs_init_sftpfs ();
-#endif
#ifdef ENABLE_VFS_SHELL
vfs_init_shell ();
#endif
+
+ vfs_plugins_load_dynamic ();
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+void
+vfs_plugins_done (void)
+{
+ vfs_plugins_unload_dynamic ();
}
/* --------------------------------------------------------------------------------------------- */
diff --git a/src/vfs/plugins_init.h b/src/vfs/plugins_init.h
index d706189747..c7fe6ed711 100644
--- a/src/vfs/plugins_init.h
+++ b/src/vfs/plugins_init.h
@@ -12,6 +12,7 @@
/*** declarations of public functions ************************************************************/
void vfs_plugins_init (void);
+void vfs_plugins_done (void);
/*** inline functions ****************************************************************************/
diff --git a/src/vfs/sftpfs/Makefile.am b/src/vfs/sftpfs/Makefile.am
index 12905d11f1..8928a02aaf 100644
--- a/src/vfs/sftpfs/Makefile.am
+++ b/src/vfs/sftpfs/Makefile.am
@@ -1,12 +1,16 @@
AM_CPPFLAGS = $(GLIB_CFLAGS) -I$(top_srcdir) $(LIBSSH_CFLAGS)
-noinst_LTLIBRARIES = libvfs-sftpfs.la
+vfsplugindir = $(vfs_plugins_dir)
+vfsplugin_LTLIBRARIES = mc-vfs-sftp.la
-libvfs_sftpfs_la_SOURCES = \
+mc_vfs_sftp_la_SOURCES = \
config_parser.c \
connection.c \
dir.c \
file.c \
internal.c internal.h \
sftpfs.c sftpfs.h
+
+mc_vfs_sftp_la_LDFLAGS = -module -avoid-version
+mc_vfs_sftp_la_LIBADD = $(GLIB_LIBS) $(LIBSSH_LIBS)
diff --git a/src/vfs/sftpfs/sftpfs.c b/src/vfs/sftpfs/sftpfs.c
index 1689e997d9..4b2cf6645b 100644
--- a/src/vfs/sftpfs/sftpfs.c
+++ b/src/vfs/sftpfs/sftpfs.c
@@ -810,7 +810,7 @@ sftpfs_cb_dir_load (struct vfs_class *me, struct vfs_s_inode *dir, const char *r
*/
void
-vfs_init_sftpfs (void)
+mc_vfs_plugin_init (void)
{
tcp_init ();
diff --git a/src/vfs/sftpfs/sftpfs.h b/src/vfs/sftpfs/sftpfs.h
index 663d9cc0c4..20da564cc3 100644
--- a/src/vfs/sftpfs/sftpfs.h
+++ b/src/vfs/sftpfs/sftpfs.h
@@ -16,7 +16,7 @@
/*** declarations of public functions ************************************************************/
-void vfs_init_sftpfs (void);
+void mc_vfs_plugin_init (void);
/*** inline functions ****************************************************************************/
diff --git a/tests/src/Makefile.am b/tests/src/Makefile.am
index abba5ad1f2..eadee54d75 100644
--- a/tests/src/Makefile.am
+++ b/tests/src/Makefile.am
@@ -32,6 +32,7 @@ TESTS = \
execute__execute_external_editor_or_viewer \
execute__execute_get_external_cmd_opts_from_config \
file_history \
+ learn_modifiers \
usermenu__test_condition \
usermenu__test_expand_format
@@ -51,6 +52,9 @@ execute__execute_get_external_cmd_opts_from_config_SOURCES = \
file_history_SOURCES = \
file_history.c
+learn_modifiers_SOURCES = \
+ learn_modifiers.c
+
usermenu__test_condition_SOURCES = \
usermenu__test_condition.c
diff --git a/tests/src/editor/Makefile.am b/tests/src/editor/Makefile.am
index 81f3d9ae37..aaeb004b52 100644
--- a/tests/src/editor/Makefile.am
+++ b/tests/src/editor/Makefile.am
@@ -18,6 +18,7 @@ EXTRA_DIST = edit_complete_word_cmd_test_data.txt.in
TESTS = \
edit_complete_word_cmd \
+ edit_fold \
edit_insert_column_of_text \
edit_replace_cmd
@@ -26,6 +27,9 @@ check_PROGRAMS = $(TESTS)
edit_complete_word_cmd_SOURCES = \
edit_complete_word_cmd.c
+edit_fold_SOURCES = \
+ edit_fold.c
+
edit_insert_column_of_text_SOURCES = \
edit_insert_column_of_text.c
diff --git a/tests/src/editor/edit_fold.c b/tests/src/editor/edit_fold.c
new file mode 100644
index 0000000000..77b91d6325
--- /dev/null
+++ b/tests/src/editor/edit_fold.c
@@ -0,0 +1,744 @@
+/*
+ src/editor - tests for edit_fold_*() functions
+
+ Copyright (C) 2025
+ Free Software Foundation, Inc.
+
+ This file is part of the Midnight Commander.
+
+ The Midnight Commander is free software: you can redistribute it
+ and/or modify it under the terms of the GNU General Public License as
+ published by the Free Software Foundation, either version 3 of the License,
+ or (at your option) any later version.
+
+ The Midnight Commander is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+ */
+
+#define TEST_SUITE_NAME "/src/editor"
+
+#include "tests/mctest.h"
+
+#include "lib/charsets.h"
+#include "src/selcodepage.h"
+
+#include "src/editor/editwidget.h"
+#include "src/editor/edit-impl.h"
+
+static WGroup owner;
+static WEdit *test_edit;
+
+/* --------------------------------------------------------------------------------------------- */
+
+/* @Before */
+static void
+setup (void)
+{
+ WRect r;
+
+ str_init_strings (NULL);
+
+ mc_global.sysconfig_dir = (char *) TEST_SHARE_DIR;
+ load_codepages_list ();
+
+ edit_options.filesize_threshold = (char *) "64M";
+
+ rect_init (&r, 0, 0, 24, 80);
+ test_edit = edit_init (NULL, &r, NULL);
+ memset (&owner, 0, sizeof (owner));
+ group_add_widget (&owner, WIDGET (test_edit));
+
+ mc_global.source_codepage = 0;
+ mc_global.display_codepage = 0;
+ cp_source = "ASCII";
+ cp_display = "ASCII";
+
+ do_set_codepage (0);
+ edit_set_codeset (test_edit);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+/* @After */
+static void
+teardown (void)
+{
+ edit_fold_flush (test_edit);
+ edit_clean (test_edit);
+ group_remove_widget (test_edit);
+ g_free (test_edit);
+
+ free_codepages_list ();
+ str_uninit_strings ();
+}
+
+/* --------------------------------------------------------------------------------------------- */
+/* edit_fold_make + edit_fold_find */
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_fold_make_and_find)
+{
+ edit_fold_t *f;
+
+ // given
+ edit_fold_make (test_edit, 10, 5);
+
+ // when
+ f = edit_fold_find (test_edit, 10);
+
+ // then
+ mctest_assert_not_null (f);
+ ck_assert_int_eq (f->line_start, 10);
+ ck_assert_int_eq (f->line_count, 5);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_fold_make_zero_count_is_noop)
+{
+ // given
+ edit_fold_make (test_edit, 10, 0);
+
+ // then
+ mctest_assert_null (edit_fold_find (test_edit, 10));
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_fold_make_negative_count_is_noop)
+{
+ // given
+ edit_fold_make (test_edit, 10, -1);
+
+ // then
+ mctest_assert_null (edit_fold_find (test_edit, 10));
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_fold_make_two_non_overlapping)
+{
+ edit_fold_t *f1, *f2;
+
+ // given
+ edit_fold_make (test_edit, 10, 3);
+ edit_fold_make (test_edit, 20, 4);
+
+ // when
+ f1 = edit_fold_find (test_edit, 10);
+ f2 = edit_fold_find (test_edit, 20);
+
+ // then
+ mctest_assert_not_null (f1);
+ ck_assert_int_eq (f1->line_start, 10);
+ ck_assert_int_eq (f1->line_count, 3);
+
+ mctest_assert_not_null (f2);
+ ck_assert_int_eq (f2->line_start, 20);
+ ck_assert_int_eq (f2->line_count, 4);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_fold_make_overlapping_replaces)
+{
+ edit_fold_t *f;
+
+ // given: create a fold at line 10 with 5 lines
+ edit_fold_make (test_edit, 10, 5);
+
+ // when: create overlapping fold that spans lines 8-18
+ edit_fold_make (test_edit, 8, 10);
+
+ // then: old fold removed, new fold present
+ f = edit_fold_find (test_edit, 8);
+ mctest_assert_not_null (f);
+ ck_assert_int_eq (f->line_start, 8);
+ ck_assert_int_eq (f->line_count, 10);
+
+ // original fold at line 10 should now be part of the new fold, not a separate one
+ f = edit_fold_find (test_edit, 10);
+ mctest_assert_not_null (f);
+ ck_assert_int_eq (f->line_start, 8);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_fold_find_empty_list)
+{
+ // then
+ mctest_assert_null (edit_fold_find (test_edit, 10));
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_fold_find_within_range)
+{
+ edit_fold_t *f;
+
+ // given: fold at line 10 hiding 5 lines (10-15)
+ edit_fold_make (test_edit, 10, 5);
+
+ // when: search for line inside fold
+ f = edit_fold_find (test_edit, 13);
+
+ // then
+ mctest_assert_not_null (f);
+ ck_assert_int_eq (f->line_start, 10);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_fold_find_outside_range)
+{
+ // given
+ edit_fold_make (test_edit, 10, 5);
+
+ // then: line before fold
+ mctest_assert_null (edit_fold_find (test_edit, 9));
+ // line after fold
+ mctest_assert_null (edit_fold_find (test_edit, 16));
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* edit_fold_is_hidden */
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_fold_is_hidden_before_fold)
+{
+ // given: fold at line 10 hiding 5 lines
+ edit_fold_make (test_edit, 10, 5);
+
+ // then
+ mctest_assert_false (edit_fold_is_hidden (test_edit, 9));
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_fold_is_hidden_fold_start)
+{
+ // given
+ edit_fold_make (test_edit, 10, 5);
+
+ // then: fold start line is NOT hidden (it's the visible summary line)
+ mctest_assert_false (edit_fold_is_hidden (test_edit, 10));
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_fold_is_hidden_inside_fold)
+{
+ // given
+ edit_fold_make (test_edit, 10, 5);
+
+ // then: lines 11-15 are hidden
+ mctest_assert_true (edit_fold_is_hidden (test_edit, 11));
+ mctest_assert_true (edit_fold_is_hidden (test_edit, 13));
+ mctest_assert_true (edit_fold_is_hidden (test_edit, 15));
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_fold_is_hidden_after_fold)
+{
+ // given
+ edit_fold_make (test_edit, 10, 5);
+
+ // then
+ mctest_assert_false (edit_fold_is_hidden (test_edit, 16));
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* edit_fold_remove */
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_fold_remove_existing)
+{
+ // given
+ edit_fold_make (test_edit, 10, 5);
+
+ // when
+ gboolean result = edit_fold_remove (test_edit, 10);
+
+ // then
+ mctest_assert_true (result);
+ mctest_assert_null (edit_fold_find (test_edit, 10));
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_fold_remove_empty_list)
+{
+ // when
+ gboolean result = edit_fold_remove (test_edit, 10);
+
+ // then
+ mctest_assert_false (result);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_fold_remove_then_find_returns_null)
+{
+ // given
+ edit_fold_make (test_edit, 10, 5);
+ edit_fold_make (test_edit, 20, 3);
+
+ // when: remove first fold
+ edit_fold_remove (test_edit, 10);
+
+ // then: first fold gone, second still present
+ mctest_assert_null (edit_fold_find (test_edit, 10));
+ mctest_assert_not_null (edit_fold_find (test_edit, 20));
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* edit_fold_next_visible / edit_fold_prev_visible */
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_fold_next_visible_no_folds)
+{
+ // then
+ ck_assert_int_eq (edit_fold_next_visible (test_edit, 10), 11);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_fold_next_visible_at_fold_start)
+{
+ // given: fold at line 10 hiding 5 lines
+ edit_fold_make (test_edit, 10, 5);
+
+ // then: from fold start, skip to line_start + line_count + 1
+ ck_assert_int_eq (edit_fold_next_visible (test_edit, 10), 16);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_fold_next_visible_before_fold)
+{
+ // given
+ edit_fold_make (test_edit, 10, 5);
+
+ // then: line before fold, normal increment
+ ck_assert_int_eq (edit_fold_next_visible (test_edit, 9), 10);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_fold_prev_visible_no_folds)
+{
+ // then
+ ck_assert_int_eq (edit_fold_prev_visible (test_edit, 10), 9);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_fold_prev_visible_after_fold)
+{
+ // given: fold at line 10 hiding 5 lines (lines 11-15 hidden)
+ edit_fold_make (test_edit, 10, 5);
+
+ // then: prev_visible(16) checks fold_find(15). Line 15 is in fold range [10,15].
+ // Since 15 > line_start(10), it jumps back to fold start.
+ ck_assert_int_eq (edit_fold_prev_visible (test_edit, 16), 10);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_fold_prev_visible_at_fold_start)
+{
+ // given
+ edit_fold_make (test_edit, 10, 5);
+
+ // then: prev_visible(10) checks fold_find(9), which returns NULL, so returns 9
+ ck_assert_int_eq (edit_fold_prev_visible (test_edit, 10), 9);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_fold_prev_visible_at_zero)
+{
+ // then: should not go below 0
+ ck_assert_int_eq (edit_fold_prev_visible (test_edit, 0), 0);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* edit_fold_flush */
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_fold_flush)
+{
+ // given
+ edit_fold_make (test_edit, 10, 5);
+ edit_fold_make (test_edit, 20, 3);
+ edit_fold_make (test_edit, 30, 7);
+
+ // when
+ edit_fold_flush (test_edit);
+
+ // then: all folds removed
+ mctest_assert_null (test_edit->folds);
+ mctest_assert_null (edit_fold_find (test_edit, 10));
+ mctest_assert_null (edit_fold_find (test_edit, 20));
+ mctest_assert_null (edit_fold_find (test_edit, 30));
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* edit_fold_inc / edit_fold_dec */
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_fold_inc_before_fold)
+{
+ edit_fold_t *f;
+
+ // given: fold at line 10 with 5 hidden lines
+ edit_fold_make (test_edit, 10, 5);
+
+ // when: insert a line before the fold (at line 5)
+ edit_fold_inc (test_edit, 5);
+
+ // then: fold shifts down by 1
+ f = edit_fold_find (test_edit, 11);
+ mctest_assert_not_null (f);
+ ck_assert_int_eq (f->line_start, 11);
+ ck_assert_int_eq (f->line_count, 5);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_fold_inc_inside_fold)
+{
+ edit_fold_t *f;
+
+ // given: fold at line 10 with 5 hidden lines
+ edit_fold_make (test_edit, 10, 5);
+
+ // when: insert a line inside the fold (at line 12)
+ edit_fold_inc (test_edit, 12);
+
+ // then: fold grows by 1
+ f = edit_fold_find (test_edit, 10);
+ mctest_assert_not_null (f);
+ ck_assert_int_eq (f->line_start, 10);
+ ck_assert_int_eq (f->line_count, 6);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_fold_dec_before_fold)
+{
+ edit_fold_t *f;
+
+ // given: fold at line 10 with 5 hidden lines
+ edit_fold_make (test_edit, 10, 5);
+
+ // when: delete a line before the fold (at line 5)
+ edit_fold_dec (test_edit, 5);
+
+ // then: fold shifts up by 1
+ f = edit_fold_find (test_edit, 9);
+ mctest_assert_not_null (f);
+ ck_assert_int_eq (f->line_start, 9);
+ ck_assert_int_eq (f->line_count, 5);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_fold_dec_inside_fold)
+{
+ edit_fold_t *f;
+
+ // given: fold at line 10 with 5 hidden lines
+ edit_fold_make (test_edit, 10, 5);
+
+ // when: delete a line inside the fold (at line 12)
+ edit_fold_dec (test_edit, 12);
+
+ // then: fold shrinks by 1
+ f = edit_fold_find (test_edit, 10);
+ mctest_assert_not_null (f);
+ ck_assert_int_eq (f->line_start, 10);
+ ck_assert_int_eq (f->line_count, 4);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_fold_dec_removes_collapsed_fold)
+{
+ // given: fold at line 10 with 1 hidden line
+ edit_fold_make (test_edit, 10, 1);
+
+ // when: delete the only hidden line
+ edit_fold_dec (test_edit, 11);
+
+ // then: fold is removed entirely
+ mctest_assert_null (edit_fold_find (test_edit, 10));
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* sorted insertion order */
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_fold_make_maintains_sorted_order)
+{
+ edit_fold_t *f;
+
+ // given: create folds out of order
+ edit_fold_make (test_edit, 30, 2);
+ edit_fold_make (test_edit, 10, 2);
+ edit_fold_make (test_edit, 20, 2);
+
+ // then: linked list should be in sorted order
+ f = test_edit->folds;
+ mctest_assert_not_null (f);
+ ck_assert_int_eq (f->line_start, 10);
+
+ f = f->next;
+ mctest_assert_not_null (f);
+ ck_assert_int_eq (f->line_start, 20);
+
+ f = f->next;
+ mctest_assert_not_null (f);
+ ck_assert_int_eq (f->line_start, 30);
+
+ mctest_assert_null (f->next);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* edit_fold_indicator_width */
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_fold_indicator_width_returns_4)
+{
+ edit_fold_t f;
+
+ // given: any fold
+ memset (&f, 0, sizeof (f));
+ f.line_start = 10;
+ f.line_count = 5;
+
+ // then: indicator "...}" is always 4 columns
+ ck_assert_int_eq (edit_fold_indicator_width (&f), 4);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_fold_indicator_width_independent_of_line_count)
+{
+ edit_fold_t f1, f2;
+
+ // given: folds with different line counts
+ memset (&f1, 0, sizeof (f1));
+ f1.line_start = 0;
+ f1.line_count = 1;
+
+ memset (&f2, 0, sizeof (f2));
+ f2.line_start = 0;
+ f2.line_count = 99999;
+
+ // then: same width regardless of line count
+ ck_assert_int_eq (edit_fold_indicator_width (&f1), edit_fold_indicator_width (&f2));
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* edit_fold_remove from inside fold */
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_fold_remove_from_inside)
+{
+ // given: fold at line 10 hiding 5 lines
+ edit_fold_make (test_edit, 10, 5);
+
+ // when: remove by a line inside the fold
+ gboolean result = edit_fold_remove (test_edit, 13);
+
+ // then: fold is removed
+ mctest_assert_true (result);
+ mctest_assert_null (edit_fold_find (test_edit, 10));
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* edit_fold_inc / edit_fold_dec edge cases */
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_fold_inc_at_fold_start)
+{
+ edit_fold_t *f;
+
+ // given: fold at line 10 with 5 hidden lines
+ edit_fold_make (test_edit, 10, 5);
+
+ // when: insert at the fold start line itself
+ edit_fold_inc (test_edit, 10);
+
+ // then: insertion at fold start line — fold is unchanged
+ // (line == line_start is neither "after" nor "inside")
+ f = edit_fold_find (test_edit, 10);
+ mctest_assert_not_null (f);
+ ck_assert_int_eq (f->line_start, 10);
+ ck_assert_int_eq (f->line_count, 5);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_fold_dec_at_fold_start)
+{
+ edit_fold_t *f;
+
+ // given: fold at line 10 with 5 hidden lines
+ edit_fold_make (test_edit, 10, 5);
+
+ // when: delete at the fold start line itself
+ edit_fold_dec (test_edit, 10);
+
+ // then: fold start stays, no change (deletion is AT start, not inside)
+ f = edit_fold_find (test_edit, 10);
+ mctest_assert_not_null (f);
+ ck_assert_int_eq (f->line_start, 10);
+ ck_assert_int_eq (f->line_count, 5);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_fold_inc_after_fold)
+{
+ edit_fold_t *f;
+
+ // given: fold at line 10 with 5 hidden lines (range 10-15)
+ edit_fold_make (test_edit, 10, 5);
+
+ // when: insert after the fold (at line 20)
+ edit_fold_inc (test_edit, 20);
+
+ // then: fold unchanged
+ f = edit_fold_find (test_edit, 10);
+ mctest_assert_not_null (f);
+ ck_assert_int_eq (f->line_start, 10);
+ ck_assert_int_eq (f->line_count, 5);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_fold_dec_after_fold)
+{
+ edit_fold_t *f;
+
+ // given: fold at line 10 with 5 hidden lines
+ edit_fold_make (test_edit, 10, 5);
+
+ // when: delete after the fold (at line 20)
+ edit_fold_dec (test_edit, 20);
+
+ // then: fold unchanged
+ f = edit_fold_find (test_edit, 10);
+ mctest_assert_not_null (f);
+ ck_assert_int_eq (f->line_start, 10);
+ ck_assert_int_eq (f->line_count, 5);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+int
+main (void)
+{
+ TCase *tc_core;
+
+ tc_core = tcase_create ("Core");
+
+ tcase_add_checked_fixture (tc_core, setup, teardown);
+
+ // Add new tests here: ***************
+ // edit_fold_make + edit_fold_find
+ tcase_add_test (tc_core, test_fold_make_and_find);
+ tcase_add_test (tc_core, test_fold_make_zero_count_is_noop);
+ tcase_add_test (tc_core, test_fold_make_negative_count_is_noop);
+ tcase_add_test (tc_core, test_fold_make_two_non_overlapping);
+ tcase_add_test (tc_core, test_fold_make_overlapping_replaces);
+ tcase_add_test (tc_core, test_fold_find_empty_list);
+ tcase_add_test (tc_core, test_fold_find_within_range);
+ tcase_add_test (tc_core, test_fold_find_outside_range);
+ // edit_fold_is_hidden
+ tcase_add_test (tc_core, test_fold_is_hidden_before_fold);
+ tcase_add_test (tc_core, test_fold_is_hidden_fold_start);
+ tcase_add_test (tc_core, test_fold_is_hidden_inside_fold);
+ tcase_add_test (tc_core, test_fold_is_hidden_after_fold);
+ // edit_fold_remove
+ tcase_add_test (tc_core, test_fold_remove_existing);
+ tcase_add_test (tc_core, test_fold_remove_empty_list);
+ tcase_add_test (tc_core, test_fold_remove_then_find_returns_null);
+ // edit_fold_next_visible / edit_fold_prev_visible
+ tcase_add_test (tc_core, test_fold_next_visible_no_folds);
+ tcase_add_test (tc_core, test_fold_next_visible_at_fold_start);
+ tcase_add_test (tc_core, test_fold_next_visible_before_fold);
+ tcase_add_test (tc_core, test_fold_prev_visible_no_folds);
+ tcase_add_test (tc_core, test_fold_prev_visible_after_fold);
+ tcase_add_test (tc_core, test_fold_prev_visible_at_fold_start);
+ tcase_add_test (tc_core, test_fold_prev_visible_at_zero);
+ // edit_fold_flush
+ tcase_add_test (tc_core, test_fold_flush);
+ // edit_fold_inc / edit_fold_dec
+ tcase_add_test (tc_core, test_fold_inc_before_fold);
+ tcase_add_test (tc_core, test_fold_inc_inside_fold);
+ tcase_add_test (tc_core, test_fold_dec_before_fold);
+ tcase_add_test (tc_core, test_fold_dec_inside_fold);
+ tcase_add_test (tc_core, test_fold_dec_removes_collapsed_fold);
+ // sorted order
+ tcase_add_test (tc_core, test_fold_make_maintains_sorted_order);
+ // edit_fold_indicator_width
+ tcase_add_test (tc_core, test_fold_indicator_width_returns_4);
+ tcase_add_test (tc_core, test_fold_indicator_width_independent_of_line_count);
+ // edit_fold_remove from inside
+ tcase_add_test (tc_core, test_fold_remove_from_inside);
+ // edit_fold_inc / edit_fold_dec edge cases
+ tcase_add_test (tc_core, test_fold_inc_at_fold_start);
+ tcase_add_test (tc_core, test_fold_dec_at_fold_start);
+ tcase_add_test (tc_core, test_fold_inc_after_fold);
+ tcase_add_test (tc_core, test_fold_dec_after_fold);
+ // ***********************************
+
+ return mctest_run_all (tc_core);
+}
+
+/* --------------------------------------------------------------------------------------------- */
diff --git a/tests/src/learn_modifiers.c b/tests/src/learn_modifiers.c
new file mode 100644
index 0000000000..70f1185437
--- /dev/null
+++ b/tests/src/learn_modifiers.c
@@ -0,0 +1,77 @@
+/*
+ src/learn - tests for modifier-aware key names
+
+ Copyright (C) 2026
+ Free Software Foundation, Inc.
+*/
+
+#define TEST_SUITE_NAME "/src/learn"
+
+#include "tests/mctest.h"
+
+#include "lib/tty/key.h"
+
+#include "src/learn.c"
+
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_learn_build_key_name_without_modifiers)
+{
+ char *name;
+
+ name = learn_build_key_name ("up", 0);
+ mctest_assert_str_eq (name, "up");
+ g_free (name);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_learn_build_key_name_single_modifiers)
+{
+ char *name;
+
+ name = learn_build_key_name ("f9", KEY_M_CTRL);
+ mctest_assert_str_eq (name, "ctrl-f9");
+ g_free (name);
+
+ name = learn_build_key_name ("right", KEY_M_ALT);
+ mctest_assert_str_eq (name, "meta-right");
+ g_free (name);
+
+ name = learn_build_key_name ("left", KEY_M_SHIFT);
+ mctest_assert_str_eq (name, "shift-left");
+ g_free (name);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_learn_build_key_name_combined_modifiers)
+{
+ char *name;
+
+ name = learn_build_key_name ("up", KEY_M_CTRL | KEY_M_SHIFT);
+ mctest_assert_str_eq (name, "ctrl-shift-up");
+ g_free (name);
+
+ name = learn_build_key_name ("pgdn", KEY_M_CTRL | KEY_M_ALT | KEY_M_SHIFT);
+ mctest_assert_str_eq (name, "ctrl-meta-shift-pgdn");
+ g_free (name);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+int
+main (void)
+{
+ TCase *tc_core;
+
+ tc_core = tcase_create ("Core");
+ tcase_add_test (tc_core, test_learn_build_key_name_without_modifiers);
+ tcase_add_test (tc_core, test_learn_build_key_name_single_modifiers);
+ tcase_add_test (tc_core, test_learn_build_key_name_combined_modifiers);
+
+ return mctest_run_all (tc_core);
+}
diff --git a/tests/vfs-plugin-test/test-plugin.c b/tests/vfs-plugin-test/test-plugin.c
new file mode 100644
index 0000000000..7d880ad9e9
--- /dev/null
+++ b/tests/vfs-plugin-test/test-plugin.c
@@ -0,0 +1,26 @@
+/*
+ * Minimal VFS test plugin.
+ *
+ * Registers a dummy "testvfs" class that does nothing useful,
+ * but proves the dynamic loading mechanism works.
+ */
+
+#include
+
+#include "lib/global.h"
+#include "lib/vfs/vfs.h"
+
+static struct vfs_class test_vfs_class;
+
+void
+mc_vfs_plugin_init (void)
+{
+ fprintf (stderr, "TEST VFS PLUGIN: mc_vfs_plugin_init() called\n");
+
+ vfs_init_class (&test_vfs_class, "testvfs", VFSF_UNKNOWN, "test");
+
+ if (vfs_register_class (&test_vfs_class))
+ fprintf (stderr, "TEST VFS PLUGIN: registered successfully with prefix 'test:'\n");
+ else
+ fprintf (stderr, "TEST VFS PLUGIN: registration FAILED\n");
+}