[PATCH] feat: add --live flag for real-time file watching
Josh Finlay <[email protected]> Sun, 12 Apr 2026 11:03:31 +0000
| Newsgroups | gmane.editors.nano.devel |
|---|---|
| Message-ID | <SY8P282MB4886C48F8F327A257C468C28E8272@SY8P282MB4886.AUSP282.PROD.OUTLOOK.COM> |
Hi, I'd like to propose a patch that adds a live file watching mode to nano, activated via a new --live command-line flag. When enabled, nano monitors the underlying file for external modifications and merges changes into the buffer in real-time, without user interaction. Motivation There are many situations where being able to watch a file while it's being written by another process is useful — tailing log files, watching build output, monitoring config files being edited by another user, collaborative editing workflows, etc. Currently this requires leaving nano and using tail -f or less +F, then re-entering the editor to make changes. With --live, nano can serve both roles simultaneously: a live-updating viewer that's also a fully functional editor. What the patch does - Adds a --live CLI flag and an ENABLE_LIVE configure option (enabled by default, disabled with --enable-tiny) - Uses kqueue on macOS/BSD and inotify on Linux for efficient event-driven file monitoring, with a stat-based mtime/size polling fallback - Integrates into the ncurses input loop via wtimeout() so file changes are detected within ~200ms even when idle - Performs line-level merging: external changes are applied only to lines the user hasn't modified, preserving unsaved edits - Tracks per-line dirty/conflict state — when an externally changed line conflicts with a user edit, it's marked as a conflict with ! in the gutter and the remote version is stored for review - Provides four new keybindings on free Meta keys: M-1 (toggle auto-follow/tail mode), M-2 (next conflict), M-4 (accept local), M-5 (accept remote) - Shows a LIVE indicator in the title bar and status bar messages on updates - Handles edge cases: file deletion/rename with automatic re-watch, self-write suppression to ignore our own saves, 16MB size limit, debounced event processing Scope of changes The patch touches 10 files (+1 new) for ~1,100 lines of additions: - src/live.c (new, ~580 lines): the watch engine, buffer diffing/merging, conflict tracking - src/definitions.h: new flags and per-line fields on linestruct/openfilestruct - src/nano.c: option parsing, field initialization, main loop hook, cleanup - src/winio.c: periodic polling in the input loop, titlebar/conflict display - src/files.c: dirty tracking, self-write detection, snapshot management - src/global.c: keybinding registration - src/prototypes.h, configure.ac, src/Makefile.am: plumbing All new code is gated behind #ifdef ENABLE_LIVE and has zero impact on builds that don't opt in. The patch compiles cleanly with zero warnings on both macOS (kqueue) and Debian Linux (inotify). Testing I've tested the following scenarios on macOS and Debian Linux “trixie": - Appending lines to a file while nano has it open - Replacing the entire file content externally - Shrinking a file (fewer lines than before) - Rapid successive modifications (debounce works correctly) - Exiting cleanly after live updates (no crashes, no leaks) I've generated a git format-patch if this approach is of interest, or to rework any aspect of the design. Looking forward to your feedback. Best regards, Josh Finlay
0001-feat-add-live-flag-for-real-time-file-watching.patch
(application/octet-stream, 52.6 KB)
From 34a4ef0355322238d8ef9078bf8a6a8c92ef0478 Mon Sep 17 00:00:00 2001 From: Josh Finlay <[email protected]> Date: Sun, 12 Apr 2026 20:32:27 +1000 Subject: [PATCH] feat: add --live flag for real-time file watching Add live file watching mode that monitors the underlying file for external changes and updates the buffer in real-time. Uses kqueue on macOS/BSD, inotify on Linux, with stat-based polling fallback. Core features: - --live CLI flag activates the mode - Line-level merge: external changes applied only to unmodified lines - Conflict detection with per-line markers for user-edited lines - Auto-follow mode (tail-like behavior) toggleable with M-1 - Self-write suppression to ignore our own saves - Debounced event processing (100ms rate limiting) - File deletion/rename detection with automatic re-watch - 16MB size limit for watched files Keybindings (in MMAIN): - M-1: Toggle auto-follow (tail mode) - M-2: Jump to next conflict - M-4: Accept local version at conflict - M-5: Accept remote version at conflict Files changed: - src/live.c: New file with watch engine, buffer diffing/merging, conflict tracking - src/definitions.h: LIVE_WATCH/LIVE_FOLLOW flags, per-line dirty/ conflict fields on linestruct, clean snapshot on openfilestruct - src/nano.c: --live option parsing, main loop integration, field init in make_new_node/copy_node/delete_node, cleanup in finish() - src/winio.c: wtimeout-based periodic polling in read_keys_from(), [LIVE] titlebar indicator, conflict markers on lines - src/files.c: Dirty line tracking in set_modified(), self-write detection and snapshot update in write_file(), field init in make_new_buffer() - src/global.c: Keybinding registration for live mode functions - src/prototypes.h: Extern and function declarations for live.c - configure.ac: --enable-live option with inotify/kqueue detection - src/Makefile.am: Added live.c to nano_SOURCES Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> added: m4/pkg.m4 Signed-off-by: Josh Finlay <[email protected]> --- .gitignore | 3 + configure.ac | 22 ++ m4/pkg.m4 | 350 +++++++++++++++++++ src/Makefile.am | 1 + src/definitions.h | 22 +- src/files.c | 23 ++ src/global.c | 23 ++ src/live.c | 856 ++++++++++++++++++++++++++++++++++++++++++++++ src/nano.c | 47 +++ src/prototypes.h | 18 + src/winio.c | 51 +++ 11 files changed, 1415 insertions(+), 1 deletion(-) create mode 100644 m4/pkg.m4 create mode 100644 src/live.c diff --git a/.gitignore b/.gitignore index 349e3d1b..66851946 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,6 @@ core /m4/.gitignore /m4/gnulib-cache.m4 /snippet/ + +.rtk +live-file-watching.md diff --git a/configure.ac b/configure.ac index a76acfa7..2ad2b6d0 100644 --- a/configure.ac +++ b/configure.ac @@ -366,6 +366,28 @@ if test "x$enable_wrapping" != xno; then AC_DEFINE(ENABLE_WRAPPING, 1, [Define this to have hard text wrapping.]) fi +AC_ARG_ENABLE(live, +AS_HELP_STRING([--enable-live], [Enable live file watching mode (default: enabled)])) +if test "x$enable_tiny" = xyes; then + if test "x$enable_live" != xyes; then + enable_live=no + fi +fi +if test "x$enable_live" != xno; then + AC_CHECK_HEADERS([sys/inotify.h], [have_inotify=yes], [have_inotify=no]) + AC_CHECK_HEADERS([sys/event.h], [have_kqueue=yes], [have_kqueue=no]) + if test "x$have_inotify" = xyes; then + AC_DEFINE(HAVE_INOTIFY, 1, [Define this if inotify is available.]) + AC_DEFINE(ENABLE_LIVE, 1, [Define this to enable live file watching.]) + elif test "x$have_kqueue" = xyes; then + AC_DEFINE(HAVE_KQUEUE, 1, [Define this if kqueue is available.]) + AC_DEFINE(ENABLE_LIVE, 1, [Define this to enable live file watching.]) + else + AC_DEFINE(ENABLE_LIVE, 1, [Define this to enable live file watching.]) + AC_MSG_WARN([Neither inotify nor kqueue found; live mode will use stat-based polling.]) + fi +fi + AC_ARG_ENABLE(debug, AS_HELP_STRING([--enable-debug], [Enable debugging (disabled by default)])) if test "x$enable_debug" = xyes; then diff --git a/m4/pkg.m4 b/m4/pkg.m4 new file mode 100644 index 00000000..ec5a70da --- /dev/null +++ b/m4/pkg.m4 @@ -0,0 +1,350 @@ +# pkg.m4 - Macros to locate and use pkg-config. -*- Autoconf -*- +# serial 13 (pkgconf) + +dnl Copyright © 2004 Scott James Remnant <[email protected]>. +dnl Copyright © 2012-2015 Dan Nicholson <[email protected]> +dnl +dnl This program is free software; you can redistribute it and/or modify +dnl it under the terms of the GNU General Public License as published by +dnl the Free Software Foundation; either version 2 of the License, or +dnl (at your option) any later version. +dnl +dnl This program is distributed in the hope that it will be useful, but +dnl WITHOUT ANY WARRANTY; without even the implied warranty of +dnl MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +dnl General Public License for more details. +dnl +dnl You should have received a copy of the GNU General Public License +dnl along with this program; if not, see <https://www.gnu.org/licenses/>. +dnl +dnl As a special exception to the GNU General Public License, if you +dnl distribute this file as part of a program that contains a +dnl configuration script generated by Autoconf, you may include it under +dnl the same distribution terms that you use for the rest of that +dnl program. + +dnl PKG_PREREQ(MIN-VERSION) +dnl ----------------------- +dnl Since: 0.29 +dnl +dnl Verify that the version of the pkg-config macros are at least +dnl MIN-VERSION. Unlike PKG_PROG_PKG_CONFIG, which checks the user's +dnl installed version of pkg-config, this checks the developer's version +dnl of pkg.m4 when generating configure. +dnl +dnl To ensure that this macro is defined, also add: +dnl m4_ifndef([PKG_PREREQ], +dnl [m4_fatal([must install pkg-config 0.29 or later before running autoconf/autogen])]) +dnl +dnl See the "Since" comment for each macro you use to see what version +dnl of the macros you require. +m4_defun([PKG_PREREQ], +[m4_define([PKG_MACROS_VERSION], [0.29.2]) +m4_if(m4_version_compare(PKG_MACROS_VERSION, [$1]), -1, + [m4_fatal([pkg.m4 version $1 or higher is required but ]PKG_MACROS_VERSION[ found])]) +])dnl PKG_PREREQ + +dnl PKG_PROG_PKG_CONFIG([MIN-VERSION], [ACTION-IF-NOT-FOUND]) +dnl --------------------------------------------------------- +dnl Since: 0.16 +dnl +dnl Search for the pkg-config tool and set the PKG_CONFIG variable to +dnl first found in the path. Checks that the version of pkg-config found +dnl is at least MIN-VERSION. If MIN-VERSION is not specified, 0.9.0 is +dnl used since that's the first version where most current features of +dnl pkg-config existed. +dnl +dnl If pkg-config is not found or older than specified, it will result +dnl in an empty PKG_CONFIG variable. To avoid widespread issues with +dnl scripts not checking it, ACTION-IF-NOT-FOUND defaults to aborting. +dnl You can specify [PKG_CONFIG=false] as an action instead, which would +dnl result in pkg-config tests failing, but no bogus error messages. +AC_DEFUN([PKG_PROG_PKG_CONFIG], +[m4_pattern_forbid([^_?PKG_[A-Z_]+$]) +m4_pattern_allow([^PKG_CONFIG(_(PATH|LIBDIR|SYSROOT_DIR|ALLOW_SYSTEM_(CFLAGS|LIBS)))?$]) +m4_pattern_allow([^PKG_CONFIG_(DISABLE_UNINSTALLED|TOP_BUILD_DIR|DEBUG_SPEW)$]) +AC_ARG_VAR([PKG_CONFIG], [path to pkg-config utility]) +AC_ARG_VAR([PKG_CONFIG_PATH], [directories to add to pkg-config's search path]) +AC_ARG_VAR([PKG_CONFIG_LIBDIR], [path overriding pkg-config's built-in search path]) + +if test "x$ac_cv_env_PKG_CONFIG_set" != "xset"; then + AC_PATH_TOOL([PKG_CONFIG], [pkg-config]) +fi +if test -n "$PKG_CONFIG"; then + _pkg_min_version=m4_default([$1], [0.9.0]) + AC_MSG_CHECKING([pkg-config is at least version $_pkg_min_version]) + if $PKG_CONFIG --atleast-pkgconfig-version $_pkg_min_version; then + AC_MSG_RESULT([yes]) + else + AC_MSG_RESULT([no]) + PKG_CONFIG="" + fi +fi +if test -z "$PKG_CONFIG"; then + m4_default([$2], [AC_MSG_ERROR([pkg-config not found])]) +fi[]dnl +])dnl PKG_PROG_PKG_CONFIG + +dnl PKG_CHECK_EXISTS(MODULES, [ACTION-IF-FOUND], [ACTION-IF-NOT-FOUND]) +dnl ------------------------------------------------------------------- +dnl Since: 0.18 +dnl +dnl Check to see whether a particular set of modules exists. Similar to +dnl PKG_CHECK_MODULES(), but does not set variables or print errors. +dnl +dnl Please remember that m4 expands AC_REQUIRE([PKG_PROG_PKG_CONFIG]) +dnl only at the first occurrence in configure.ac, so if the first place +dnl it's called might be skipped (such as if it is within an "if", you +dnl have to call PKG_CHECK_EXISTS manually +AC_DEFUN([PKG_CHECK_EXISTS], +[AC_REQUIRE([PKG_PROG_PKG_CONFIG])dnl +if test -n "$PKG_CONFIG" && \ + AC_RUN_LOG([$PKG_CONFIG --exists --print-errors "$1"]); then + m4_default([$2], [:]) +m4_ifvaln([$3], [else + $3])dnl +fi]) + +dnl _PKG_CONFIG([VARIABLE], [COMMAND], [MODULES]) +dnl --------------------------------------------- +dnl Internal wrapper calling pkg-config via PKG_CONFIG and setting +dnl pkg_failed based on the result. +m4_define([_PKG_CONFIG], +[if test -n "$$1"; then + pkg_cv_[]$1="$$1" + elif test -n "$PKG_CONFIG"; then + PKG_CHECK_EXISTS([$3], + [pkg_cv_[]$1=`$PKG_CONFIG --[]$2 "$3" 2>/dev/null` + test "x$?" != "x0" && pkg_failed=yes ], + [pkg_failed=yes]) + else + pkg_failed=untried +fi[]dnl +])dnl _PKG_CONFIG + +dnl _PKG_SHORT_ERRORS_SUPPORTED +dnl --------------------------- +dnl Internal check to see if pkg-config supports short errors. +AC_DEFUN([_PKG_SHORT_ERRORS_SUPPORTED], +[AC_REQUIRE([PKG_PROG_PKG_CONFIG]) +if $PKG_CONFIG --atleast-pkgconfig-version 0.20; then + _pkg_short_errors_supported=yes +else + _pkg_short_errors_supported=no +fi[]dnl +])dnl _PKG_SHORT_ERRORS_SUPPORTED + + +dnl PKG_CHECK_MODULES(VARIABLE-PREFIX, MODULES, [ACTION-IF-FOUND], +dnl [ACTION-IF-NOT-FOUND]) +dnl -------------------------------------------------------------- +dnl Since: 0.4.0 +dnl +dnl Note that if there is a possibility the first call to +dnl PKG_CHECK_MODULES might not happen, you should be sure to include an +dnl explicit call to PKG_PROG_PKG_CONFIG in your configure.ac +AC_DEFUN([PKG_CHECK_MODULES], +[AC_REQUIRE([PKG_PROG_PKG_CONFIG])dnl +AC_ARG_VAR([$1][_CFLAGS], [C compiler flags for $1, overriding pkg-config])dnl +AC_ARG_VAR([$1][_LIBS], [linker flags for $1, overriding pkg-config])dnl + +pkg_failed=no +AC_MSG_CHECKING([for $2]) + +_PKG_CONFIG([$1][_CFLAGS], [cflags], [$2]) +_PKG_CONFIG([$1][_LIBS], [libs], [$2]) + +m4_define([_PKG_TEXT], [Alternatively, you may set the environment variables $1[]_CFLAGS +and $1[]_LIBS to avoid the need to call pkg-config. +See the pkg-config man page for more details.]) + +if test $pkg_failed = yes; then + AC_MSG_RESULT([no]) + _PKG_SHORT_ERRORS_SUPPORTED + if test $_pkg_short_errors_supported = yes; then + $1[]_PKG_ERRORS=`$PKG_CONFIG --short-errors --print-errors --cflags --libs "$2" 2>&1` + else + $1[]_PKG_ERRORS=`$PKG_CONFIG --print-errors --cflags --libs "$2" 2>&1` + fi + # Put the nasty error message in config.log where it belongs + echo "$$1[]_PKG_ERRORS" >&AS_MESSAGE_LOG_FD + + m4_default([$4], [AC_MSG_ERROR( +[Package requirements ($2) were not met: + +$$1_PKG_ERRORS + +Consider adjusting the PKG_CONFIG_PATH environment variable if you +installed software in a non-standard prefix. + +_PKG_TEXT])[]dnl + ]) +elif test $pkg_failed = untried; then + AC_MSG_RESULT([no]) + m4_default([$4], [AC_MSG_FAILURE( +[The pkg-config script could not be found or is too old. Make sure it +is in your PATH or set the PKG_CONFIG environment variable to the full +path to pkg-config. + +_PKG_TEXT + +To get pkg-config, see <http://pkg-config.freedesktop.org/>.])[]dnl + ]) +else + $1[]_CFLAGS=$pkg_cv_[]$1[]_CFLAGS + $1[]_LIBS=$pkg_cv_[]$1[]_LIBS + AC_MSG_RESULT([yes]) + $3 +fi[]dnl +])dnl PKG_CHECK_MODULES + + +dnl PKG_CHECK_MODULES_STATIC(VARIABLE-PREFIX, MODULES, [ACTION-IF-FOUND], +dnl [ACTION-IF-NOT-FOUND]) +dnl --------------------------------------------------------------------- +dnl Since: 0.29 +dnl +dnl Checks for existence of MODULES and gathers its build flags with +dnl static libraries enabled. Sets VARIABLE-PREFIX_CFLAGS from --cflags +dnl and VARIABLE-PREFIX_LIBS from --libs. +dnl +dnl Note that if there is a possibility the first call to +dnl PKG_CHECK_MODULES_STATIC might not happen, you should be sure to +dnl include an explicit call to PKG_PROG_PKG_CONFIG in your +dnl configure.ac. +AC_DEFUN([PKG_CHECK_MODULES_STATIC], +[AC_REQUIRE([PKG_PROG_PKG_CONFIG])dnl +_save_PKG_CONFIG=$PKG_CONFIG +PKG_CONFIG="$PKG_CONFIG --static" +PKG_CHECK_MODULES($@) +PKG_CONFIG=$_save_PKG_CONFIG[]dnl +])dnl PKG_CHECK_MODULES_STATIC + + +dnl PKG_INSTALLDIR([DIRECTORY]) +dnl ------------------------- +dnl Since: 0.27 +dnl +dnl Substitutes the variable pkgconfigdir as the location where a module +dnl should install pkg-config .pc files. By default the directory is +dnl $libdir/pkgconfig, but the default can be changed by passing +dnl DIRECTORY. The user can override through the --with-pkgconfigdir +dnl parameter. +AC_DEFUN([PKG_INSTALLDIR], +[m4_pushdef([pkg_default], [m4_default([$1], ['${libdir}/pkgconfig'])]) +m4_pushdef([pkg_description], + [pkg-config installation directory @<:@]pkg_default[@:>@]) +AC_ARG_WITH([pkgconfigdir], + [AS_HELP_STRING([--with-pkgconfigdir], pkg_description)],, + [with_pkgconfigdir=]pkg_default) +AC_SUBST([pkgconfigdir], [$with_pkgconfigdir]) +m4_popdef([pkg_default]) +m4_popdef([pkg_description]) +])dnl PKG_INSTALLDIR + + +dnl PKG_NOARCH_INSTALLDIR([DIRECTORY]) +dnl -------------------------------- +dnl Since: 0.27 +dnl +dnl Substitutes the variable noarch_pkgconfigdir as the location where a +dnl module should install arch-independent pkg-config .pc files. By +dnl default the directory is $datadir/pkgconfig, but the default can be +dnl changed by passing DIRECTORY. The user can override through the +dnl --with-noarch-pkgconfigdir parameter. +AC_DEFUN([PKG_NOARCH_INSTALLDIR], +[m4_pushdef([pkg_default], [m4_default([$1], ['${datadir}/pkgconfig'])]) +m4_pushdef([pkg_description], + [pkg-config arch-independent installation directory @<:@]pkg_default[@:>@]) +AC_ARG_WITH([noarch-pkgconfigdir], + [AS_HELP_STRING([--with-noarch-pkgconfigdir], pkg_description)],, + [with_noarch_pkgconfigdir=]pkg_default) +AC_SUBST([noarch_pkgconfigdir], [$with_noarch_pkgconfigdir]) +m4_popdef([pkg_default]) +m4_popdef([pkg_description]) +])dnl PKG_NOARCH_INSTALLDIR + + +dnl PKG_CHECK_VAR(VARIABLE, MODULE, CONFIG-VARIABLE, +dnl [ACTION-IF-FOUND], [ACTION-IF-NOT-FOUND]) +dnl ------------------------------------------- +dnl Since: 0.28 +dnl +dnl Retrieves the value of the pkg-config variable for the given module. +AC_DEFUN([PKG_CHECK_VAR], +[AC_REQUIRE([PKG_PROG_PKG_CONFIG])dnl +AC_ARG_VAR([$1], [value of $3 for $2, overriding pkg-config])dnl + +_PKG_CONFIG([$1], [variable="][$3]["], [$2]) +AS_VAR_COPY([$1], [pkg_cv_][$1]) + +AS_VAR_IF([$1], [""], [$5], [$4])dnl +])dnl PKG_CHECK_VAR + +dnl PKG_WITH_MODULES(VARIABLE-PREFIX, MODULES, +dnl [ACTION-IF-FOUND],[ACTION-IF-NOT-FOUND], +dnl [DESCRIPTION], [DEFAULT]) +dnl ------------------------------------------ +dnl +dnl Prepare a "--with-" configure option using the lowercase +dnl [VARIABLE-PREFIX] name, merging the behaviour of AC_ARG_WITH and +dnl PKG_CHECK_MODULES in a single macro. +AC_DEFUN([PKG_WITH_MODULES], +[ +m4_pushdef([with_arg], m4_tolower([$1])) + +m4_pushdef([description], + [m4_default([$5], [build with ]with_arg[ support])]) + +m4_pushdef([def_arg], [m4_default([$6], [auto])]) +m4_pushdef([def_action_if_found], [AS_TR_SH([with_]with_arg)=yes]) +m4_pushdef([def_action_if_not_found], [AS_TR_SH([with_]with_arg)=no]) + +m4_case(def_arg, + [yes],[m4_pushdef([with_without], [--without-]with_arg)], + [m4_pushdef([with_without],[--with-]with_arg)]) + +AC_ARG_WITH(with_arg, + AS_HELP_STRING(with_without, description[ @<:@default=]def_arg[@:>@]),, + [AS_TR_SH([with_]with_arg)=def_arg]) + +AS_CASE([$AS_TR_SH([with_]with_arg)], + [yes],[PKG_CHECK_MODULES([$1],[$2],$3,$4)], + [auto],[PKG_CHECK_MODULES([$1],[$2], + [m4_n([def_action_if_found]) $3], + [m4_n([def_action_if_not_found]) $4])]) + +m4_popdef([with_arg]) +m4_popdef([description]) +m4_popdef([def_arg]) + +])dnl PKG_WITH_MODULES + +dnl PKG_HAVE_WITH_MODULES(VARIABLE-PREFIX, MODULES, +dnl [DESCRIPTION], [DEFAULT]) +dnl ----------------------------------------------- +dnl +dnl Convenience macro to trigger AM_CONDITIONAL after PKG_WITH_MODULES +dnl check._[VARIABLE-PREFIX] is exported as make variable. +AC_DEFUN([PKG_HAVE_WITH_MODULES], +[ +PKG_WITH_MODULES([$1],[$2],,,[$3],[$4]) + +AM_CONDITIONAL([HAVE_][$1], + [test "$AS_TR_SH([with_]m4_tolower([$1]))" = "yes"]) +])dnl PKG_HAVE_WITH_MODULES + +dnl PKG_HAVE_DEFINE_WITH_MODULES(VARIABLE-PREFIX, MODULES, +dnl [DESCRIPTION], [DEFAULT]) +dnl ------------------------------------------------------ +dnl +dnl Convenience macro to run AM_CONDITIONAL and AC_DEFINE after +dnl PKG_WITH_MODULES check. HAVE_[VARIABLE-PREFIX] is exported as make +dnl and preprocessor variable. +AC_DEFUN([PKG_HAVE_DEFINE_WITH_MODULES], +[ +PKG_HAVE_WITH_MODULES([$1],[$2],[$3],[$4]) + +AS_IF([test "$AS_TR_SH([with_]m4_tolower([$1]))" = "yes"], + [AC_DEFINE([HAVE_][$1], 1, [Enable ]m4_tolower([$1])[ support])]) +])dnl PKG_HAVE_DEFINE_WITH_MODULES diff --git a/src/Makefile.am b/src/Makefile.am index a678e29b..cb8e11f5 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -33,6 +33,7 @@ nano_SOURCES = \ global.c \ help.c \ history.c \ + live.c \ move.c \ nano.c \ prompt.c \ diff --git a/src/definitions.h b/src/definitions.h index 265bad60..f2579082 100644 --- a/src/definitions.h +++ b/src/definitions.h @@ -380,7 +380,11 @@ enum { MINIBAR, ZERO, MODERN_BINDINGS, - SOLO_SIDESCROLL + SOLO_SIDESCROLL, +#ifdef ENABLE_LIVE + LIVE_WATCH, + LIVE_FOLLOW, +#endif }; /* Structure types. */ @@ -489,6 +493,14 @@ typedef struct linestruct { bool has_anchor; /* Whether the user has placed an anchor at this line. */ #endif +#ifdef ENABLE_LIVE + bool is_dirty; + /* Whether the user has modified this line since last load/save. */ + bool has_conflict; + /* Whether an external change conflicts with a user edit on this line. */ + char *remote_version; + /* The external version of this line when a conflict exists. */ +#endif } linestruct; #ifndef NANO_TINY @@ -598,6 +610,14 @@ typedef struct openfilestruct { #endif bool modified; /* Whether the file has been modified. */ +#ifdef ENABLE_LIVE + linestruct *clean_filetop; + /* Snapshot of the buffer at last load/save, used as merge base. */ + bool self_wrote; + /* Flag to suppress file-watch events triggered by our own save. */ + int conflict_count; + /* Number of unresolved conflicts in this buffer. */ +#endif #ifdef ENABLE_COLOR syntaxtype *syntax; /* The syntax that applies to this file, if any. */ diff --git a/src/files.c b/src/files.c index a8a37cc9..27f8c66a 100644 --- a/src/files.c +++ b/src/files.c @@ -104,6 +104,11 @@ void make_new_buffer(void) #ifdef ENABLE_COLOR openfile->syntax = NULL; #endif +#ifdef ENABLE_LIVE + openfile->clean_filetop = NULL; + openfile->self_wrote = FALSE; + openfile->conflict_count = 0; +#endif } /* Return the given file name in a way that fits within the given space. */ @@ -497,6 +502,12 @@ bool open_buffer(const char *filename, bool new_one) * then update the title bar to display the buffer's new status. */ void set_modified(void) { +#ifdef ENABLE_LIVE + /* Mark the current line as user-modified for live watch conflict tracking. */ + if (ISSET(LIVE_WATCH) && openfile->current != NULL) + mark_line_dirty(openfile->current); +#endif + if (openfile->modified) return; @@ -2064,6 +2075,18 @@ bool write_file(const char *name, FILE *thefile, bool normal, titlebar(NULL); } +#ifdef ENABLE_LIVE + /* In live mode, suppress the watch event from our own write, + * update the clean snapshot, and clear dirty flags. */ + if (ISSET(LIVE_WATCH) && annotate && method == OVERWRITE) { + openfile->self_wrote = TRUE; + take_clean_snapshot(); + clear_all_dirty_flags(); + /* Re-establish the watch in case the file was atomically replaced. */ + rewatch_file(realname); + } +#endif + #ifndef NANO_TINY if (ISSET(MINIBAR) && !ISSET(ZERO) && LINES > 1 && annotate) report_size = TRUE; diff --git a/src/global.c b/src/global.c index b93f035e..eeffef1e 100644 --- a/src/global.c +++ b/src/global.c @@ -691,6 +691,12 @@ void shortcut_init(void) const char *anchor_gist = N_("Place or remove an anchor at the current line"); const char *prevanchor_gist = N_("Jump backward to the nearest anchor"); const char *nextanchor_gist = N_("Jump forward to the nearest anchor"); +#endif +#ifdef ENABLE_LIVE + const char *livefollow_gist = N_("Toggle auto-follow in live watch mode"); + const char *nextconflict_gist = N_("Jump to the next conflict"); + const char *acceptlocal_gist = N_("Keep local version at current conflict"); + const char *acceptremote_gist = N_("Take remote version at current conflict"); #endif const char *case_gist = N_("Toggle the case sensitivity of the search"); const char *reverse_gist = N_("Reverse the direction of the search"); @@ -1122,6 +1128,17 @@ void shortcut_init(void) N_("Cycle"), WHENHELP(cycle_gist), BLANKAFTER); #endif +#ifdef ENABLE_LIVE + add_to_funcs(toggle_live_follow, MMAIN, + N_("Follow"), WHENHELP(livefollow_gist), TOGETHER); + add_to_funcs(goto_next_conflict, MMAIN, + N_("Nxt Conflct"), WHENHELP(nextconflict_gist), TOGETHER); + add_to_funcs(accept_local_conflict, MMAIN, + N_("Keep Local"), WHENHELP(acceptlocal_gist), TOGETHER); + add_to_funcs(accept_remote_conflict, MMAIN, + N_("Take Remote"), WHENHELP(acceptremote_gist), BLANKAFTER); +#endif + add_to_funcs(do_savefile, MMAIN, N_("Save"), WHENHELP(savefile_gist), BLANKAFTER); @@ -1606,6 +1623,12 @@ void shortcut_init(void) #if defined(ENABLE_EXTRA) && defined(NCURSES_VERSION_PATCH) add_to_sclist(MMAIN, "M-&", 0, show_curses_version, 0); #endif +#ifdef ENABLE_LIVE + add_to_sclist(MMAIN, "M-1", 0, toggle_live_follow, 0); + add_to_sclist(MMAIN, "M-2", 0, goto_next_conflict, 0); + add_to_sclist(MMAIN, "M-4", 0, accept_local_conflict, 0); + add_to_sclist(MMAIN, "M-5", 0, accept_remote_conflict, 0); +#endif #ifndef NANO_TINY add_to_sclist((MMOST & ~MMAIN) | MYESNO, "", KEY_CANCEL, do_cancel, 0); add_to_sclist(MMAIN, "", KEY_CENTER, do_center, 0); diff --git a/src/live.c b/src/live.c new file mode 100644 index 00000000..56ad3c34 --- /dev/null +++ b/src/live.c @@ -0,0 +1,856 @@ +/************************************************************************** + * live.c -- This file is part of GNU nano. * + * * + * Copyright (C) 2026 Free Software Foundation, Inc. * + * * + * GNU nano 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. * + * * + * GNU nano 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 https://gnu.org/licenses/. * + * * + **************************************************************************/ + +#include "prototypes.h" + +#ifdef ENABLE_LIVE + +#include <errno.h> +#include <fcntl.h> +#include <string.h> +#include <sys/stat.h> +#include <sys/types.h> +#include <time.h> +#include <unistd.h> + +#ifdef HAVE_INOTIFY +#include <sys/inotify.h> +#endif + +#ifdef HAVE_KQUEUE +#include <sys/event.h> +#endif + +/* The file descriptor for the watch mechanism (inotify fd or kqueue fd). */ +int live_watch_fd = -1; + +/* The watch descriptor for the specific file (inotify wd or file fd for kqueue). */ +static int live_file_wd = -1; + +/* For kqueue, we need a separate fd open on the watched file. */ +#ifdef HAVE_KQUEUE +static int live_file_fd = -1; +#endif + +/* Debounce: the time of the last processed change. */ +static struct timespec last_change_time = {0, 0}; + +/* Whether an event was received but not yet processed (rate-limited). */ +static bool change_pending = FALSE; + +/* Debounce interval in milliseconds. */ +#define DEBOUNCE_MS 100 + +/* Maximum file size for live watching (16 MB). */ +#define LIVE_MAX_SIZE (16 * 1024 * 1024) + +/* Take a snapshot of the buffer's content for use as a merge base. + * This duplicates the entire linked list of lines. */ +void take_clean_snapshot(void) +{ + if (openfile->clean_filetop != NULL) + free_lines(openfile->clean_filetop); + + openfile->clean_filetop = copy_buffer(openfile->filetop); +} + +/* Clear all dirty flags on every line in the current buffer. */ +void clear_all_dirty_flags(void) +{ + for (linestruct *line = openfile->filetop; line != NULL; line = line->next) + line->is_dirty = FALSE; +} + +/* Clear all conflict flags and free remote versions. */ +void clear_all_conflicts(void) +{ + for (linestruct *line = openfile->filetop; line != NULL; line = line->next) { + if (line->has_conflict) { + line->has_conflict = FALSE; + free(line->remote_version); + line->remote_version = NULL; + } + } + openfile->conflict_count = 0; +} + +/* Initialize the live file watch for the given filename. + * Returns TRUE on success, FALSE on failure. */ +bool init_live_watch(const char *filename) +{ + struct stat st; + + if (filename == NULL || filename[0] == '\0') + return FALSE; + + /* Don't watch files that are too large. */ + if (stat(filename, &st) == 0 && st.st_size > LIVE_MAX_SIZE) { + statusline(ALERT, _("File too large for live watching")); + return FALSE; + } + +#ifdef HAVE_INOTIFY + live_watch_fd = inotify_init1(IN_NONBLOCK | IN_CLOEXEC); + if (live_watch_fd < 0) { + statusline(ALERT, _("Could not initialize inotify")); + return FALSE; + } + + live_file_wd = inotify_add_watch(live_watch_fd, filename, + IN_MODIFY | IN_MOVE_SELF | IN_DELETE_SELF | IN_ATTRIB); + if (live_file_wd < 0) { + close(live_watch_fd); + live_watch_fd = -1; + statusline(ALERT, _("Could not watch file")); + return FALSE; + } +#elif defined(HAVE_KQUEUE) + live_watch_fd = kqueue(); + if (live_watch_fd < 0) { + statusline(ALERT, _("Could not initialize kqueue")); + return FALSE; + } + + live_file_fd = open(filename, O_RDONLY | O_CLOEXEC); + if (live_file_fd < 0) { + close(live_watch_fd); + live_watch_fd = -1; + statusline(ALERT, _("Could not open file for watching")); + return FALSE; + } + + struct kevent change; + EV_SET(&change, live_file_fd, EVFILT_VNODE, + EV_ADD | EV_ENABLE | EV_CLEAR, + NOTE_WRITE | NOTE_EXTEND | + NOTE_DELETE | NOTE_RENAME | NOTE_ATTRIB, + 0, NULL); + + if (kevent(live_watch_fd, &change, 1, NULL, 0, NULL) < 0) { + close(live_file_fd); + close(live_watch_fd); + live_file_fd = -1; + live_watch_fd = -1; + statusline(ALERT, _("Could not watch file")); + return FALSE; + } + live_file_wd = live_file_fd; +#else + /* Stat-based fallback: just use the fd field as a flag. */ + live_watch_fd = 0; + live_file_wd = 0; +#endif + + /* Take the initial clean snapshot and clear dirty flags. */ + take_clean_snapshot(); + clear_all_dirty_flags(); + openfile->self_wrote = FALSE; + openfile->conflict_count = 0; + + SET(LIVE_WATCH); + SET(LIVE_FOLLOW); + + return TRUE; +} + +/* Re-establish the file watch after the file was replaced (e.g. by + * save-and-rename or external tools that do atomic replace). */ +void rewatch_file(const char *filename) +{ +#ifdef HAVE_INOTIFY + if (live_watch_fd >= 0 && live_file_wd >= 0) { + inotify_rm_watch(live_watch_fd, live_file_wd); + live_file_wd = inotify_add_watch(live_watch_fd, filename, + IN_MODIFY | IN_MOVE_SELF | IN_DELETE_SELF | IN_ATTRIB); + } +#elif defined(HAVE_KQUEUE) + if (live_watch_fd >= 0 && live_file_fd >= 0) { + close(live_file_fd); + + live_file_fd = open(filename, O_RDONLY | O_CLOEXEC); + if (live_file_fd >= 0) { + struct kevent change; + EV_SET(&change, live_file_fd, EVFILT_VNODE, + EV_ADD | EV_ENABLE | EV_CLEAR, + NOTE_WRITE | NOTE_EXTEND | + NOTE_DELETE | NOTE_RENAME | NOTE_ATTRIB, + 0, NULL); + kevent(live_watch_fd, &change, 1, NULL, 0, NULL); + live_file_wd = live_file_fd; + } + } +#endif +} + +/* Close the file watch and clean up resources. */ +void close_live_watch(void) +{ +#ifdef HAVE_INOTIFY + if (live_watch_fd >= 0) { + if (live_file_wd >= 0) + inotify_rm_watch(live_watch_fd, live_file_wd); + close(live_watch_fd); + } +#elif defined(HAVE_KQUEUE) + if (live_file_fd >= 0) + close(live_file_fd); + if (live_watch_fd >= 0) + close(live_watch_fd); + live_file_fd = -1; +#endif + + live_watch_fd = -1; + live_file_wd = -1; + + if (openfile->clean_filetop != NULL) { + free_lines(openfile->clean_filetop); + openfile->clean_filetop = NULL; + } + + clear_all_conflicts(); + + UNSET(LIVE_WATCH); + UNSET(LIVE_FOLLOW); +} + +/* Check whether enough time has passed since we last processed a change. + * This is a rate-limiter: it returns TRUE if we should process now, + * and if so, updates the timestamp. Returns FALSE if we processed + * too recently (caller should skip this event — but the event is NOT + * lost, because kqueue/inotify will fire again on the next poll). */ +static bool rate_limit_ok(void) +{ + struct timespec now; + + clock_gettime(CLOCK_MONOTONIC, &now); + + long elapsed_ms = (now.tv_sec - last_change_time.tv_sec) * 1000 + + (now.tv_nsec - last_change_time.tv_nsec) / 1000000; + + if (elapsed_ms >= DEBOUNCE_MS) { + last_change_time = now; + return TRUE; + } + + return FALSE; +} + +/* Allocate a new linestruct and append it to the list. + * Sets all live/color/anchor fields to clean defaults. */ +static linestruct *append_new_line(linestruct **head, linestruct **tail, + ssize_t *num, const char *text) +{ + linestruct *newline = make_new_node(*tail); + newline->data = copy_of(text); + newline->lineno = ++(*num); + + if (*head == NULL) + *head = newline; + else + splice_node(*tail, newline); + + *tail = newline; + return newline; +} + +/* Read the file from disk into a temporary linked list of lines, + * matching nano's read_file() behavior — in particular, a file that + * ends with a newline will have a trailing empty-string line, exactly + * as nano's buffer does. + * Returns the first line, or NULL on failure. Sets *count. */ +static linestruct *read_file_to_lines(const char *filename, ssize_t *count) +{ + FILE *f = fopen(filename, "rb"); + linestruct *head = NULL; + linestruct *tail = NULL; + ssize_t num = 0; + int ch; + size_t len = 0; + size_t cap = 256; + char *buf; + bool last_was_nl = FALSE; + + if (f == NULL) + return NULL; + + buf = nmalloc(cap); + + /* Read byte by byte, matching the way nano's read_file works. */ + while ((ch = getc(f)) != EOF) { + if (ch == '\n') { + buf[len] = '\0'; + + /* Strip trailing CR (DOS format). */ + if (len > 0 && buf[len - 1] == '\r') + buf[len - 1] = '\0'; + + append_new_line(&head, &tail, &num, buf); + len = 0; + last_was_nl = TRUE; + } else { + if (len + 1 >= cap) { + cap *= 2; + buf = nrealloc(buf, cap); + } + buf[len++] = (char)ch; + last_was_nl = FALSE; + } + } + + fclose(f); + + /* If there is remaining data after the last newline (file does NOT + * end with a newline), store it as the final line. */ + if (len > 0) { + buf[len] = '\0'; + if (len > 0 && buf[len - 1] == '\r') + buf[len - 1] = '\0'; + append_new_line(&head, &tail, &num, buf); + } else if (last_was_nl || head != NULL) { + /* The file ended with a newline — add the trailing blank line + * that nano's read_file would also create. */ + append_new_line(&head, &tail, &num, ""); + } + + free(buf); + + /* Empty file: single empty line. */ + if (head == NULL) { + append_new_line(&head, &tail, &num, ""); + } + + *count = num; + return head; +} + +/* Count lines in a linked list. */ +static ssize_t count_lines(const linestruct *head) +{ + ssize_t n = 0; + for (const linestruct *line = head; line != NULL; line = line->next) + n++; + return n; +} + +/* Merge external changes into the current buffer. + * Uses a simple line-by-line comparison: + * - Compare old_clean (snapshot) vs new_disk (just read) + * - For lines that changed externally and are NOT dirty in the buffer, update them + * - For lines that changed externally and ARE dirty, mark as conflict + * - Handle insertions and deletions */ +static void merge_external_changes(linestruct *new_disk, ssize_t new_count) +{ + linestruct *old_clean = openfile->clean_filetop; + ssize_t old_count = count_lines(old_clean); + + /* Walk through lines comparing old snapshot vs new disk content. + * For simplicity, use a line-by-line approach up to the minimum + * of the two lengths, then handle tail insertions/deletions. */ + ssize_t min_count = (old_count < new_count) ? old_count : new_count; + linestruct *buf_line = openfile->filetop; + linestruct *old_line = old_clean; + linestruct *new_line = new_disk; + int conflicts = 0; + bool buffer_changed = FALSE; + + for (ssize_t i = 1; i <= min_count; i++) { + bool external_changed = (strcmp(old_line->data, new_line->data) != 0); + + if (external_changed && buf_line != NULL) { + if (buf_line->is_dirty) { + /* Conflict: user edited this line and it also changed on disk. */ + buf_line->has_conflict = TRUE; + free(buf_line->remote_version); + buf_line->remote_version = copy_of(new_line->data); + conflicts++; + } else { + /* Clean line changed externally: update it. */ + free(buf_line->data); + buf_line->data = copy_of(new_line->data); +#ifdef ENABLE_COLOR + free(buf_line->multidata); + buf_line->multidata = NULL; +#endif + buffer_changed = TRUE; + } + } + + if (buf_line != NULL) buf_line = buf_line->next; + old_line = old_line->next; + new_line = new_line->next; + } + + /* Handle lines added externally at the end. */ + if (new_count > old_count && buf_line == NULL) { + /* buf_line is NULL, meaning we're past the end of the buffer. + * Append to the buffer's last line. */ + linestruct *last = openfile->filebot; + + for (ssize_t i = old_count + 1; i <= new_count; i++) { + if (new_line == NULL) break; + + linestruct *added = make_new_node(last); + added->data = copy_of(new_line->data); + added->lineno = last->lineno + 1; +#ifdef ENABLE_LIVE + added->is_dirty = FALSE; + added->has_conflict = FALSE; + added->remote_version = NULL; +#endif +#ifdef ENABLE_COLOR + added->multidata = NULL; +#endif +#ifndef NANO_TINY + added->has_anchor = FALSE; +#endif + splice_node(last, added); + last = added; + openfile->filebot = added; + + new_line = new_line->next; + buffer_changed = TRUE; + } + } else if (new_count > old_count && buf_line != NULL) { + /* There are extra lines on disk beyond the old snapshot, but + * the buffer still has lines remaining. Insert before buf_line. */ + linestruct *insert_after = buf_line->prev; + if (insert_after == NULL) + insert_after = openfile->filetop; + + for (ssize_t i = old_count + 1; i <= new_count; i++) { + if (new_line == NULL) break; + + linestruct *added = make_new_node(insert_after); + added->data = copy_of(new_line->data); + added->lineno = 0; /* Will be renumbered. */ +#ifdef ENABLE_LIVE + added->is_dirty = FALSE; + added->has_conflict = FALSE; + added->remote_version = NULL; +#endif +#ifdef ENABLE_COLOR + added->multidata = NULL; +#endif +#ifndef NANO_TINY + added->has_anchor = FALSE; +#endif + splice_node(insert_after, added); + insert_after = added; + + new_line = new_line->next; + buffer_changed = TRUE; + } + } + + /* Handle lines deleted externally at the end. */ + if (new_count < old_count) { + /* Remove trailing clean lines from the buffer, but not dirty ones. */ + linestruct *line = openfile->filebot; + + for (ssize_t i = old_count; i > new_count && line != NULL; i--) { + /* Never delete the very last line — nano requires at least one. */ + if (line->prev == NULL) + break; + + /* Never delete nano's trailing blank "magic line". */ + if (line == openfile->filebot && line->data[0] == '\0' + && line->prev != NULL) + break; + + if (line->is_dirty) { + /* Dirty line was deleted externally: hard conflict. */ + line->has_conflict = TRUE; + free(line->remote_version); + line->remote_version = copy_of(""); /* Remote says "deleted". */ + conflicts++; + line = line->prev; + } else { + linestruct *prev = line->prev; + + /* If any buffer pointers reference this line, move them. */ + if (openfile->current == line) { + openfile->current = prev; + openfile->current_x = 0; + } + if (openfile->edittop == line) + openfile->edittop = prev; +#ifndef NANO_TINY + if (openfile->mark == line) + openfile->mark = NULL; +#endif + + unlink_node(line); + delete_node(line); + openfile->filebot = prev; + prev->next = NULL; + + line = prev; + buffer_changed = TRUE; + } + } + } + + openfile->conflict_count = conflicts; + + /* Renumber lines and recalculate totsize. */ + if (buffer_changed) { + renumber_from(openfile->filetop); + + /* Ensure filebot is actually the last node in the list. */ + linestruct *bot = openfile->filetop; + while (bot->next != NULL) + bot = bot->next; + openfile->filebot = bot; + + openfile->totsize = number_of_characters_in(openfile->filetop, + openfile->filebot); + } + + /* Update the clean snapshot to the new disk version. */ + take_clean_snapshot(); + + if (buffer_changed) + refresh_needed = TRUE; + + if (conflicts > 0) + statusline(AHEM, _("%d conflict(s) -- M-2 to review"), conflicts); + else if (buffer_changed) + statusline(REMARK, _("Live: buffer updated from disk")); +} + +/* Check for file watch events and process them. + * Returns TRUE if an event was handled, FALSE otherwise. */ +bool check_live_watch(void) +{ + if (!ISSET(LIVE_WATCH) || live_watch_fd < 0) + return FALSE; + + /* If we recently wrote the file ourselves, ignore this event. */ + if (openfile->self_wrote) { + /* Consume any pending events. */ +#ifdef HAVE_INOTIFY + char buf[4096]; + while (read(live_watch_fd, buf, sizeof(buf)) > 0) + ; +#elif defined(HAVE_KQUEUE) + struct kevent event; + struct timespec zero = {0, 0}; + while (kevent(live_watch_fd, NULL, 0, &event, 1, &zero) > 0) + ; +#endif + openfile->self_wrote = FALSE; + return FALSE; + } + + bool got_event = FALSE; + + +#ifdef HAVE_INOTIFY + { + char buf[4096] + __attribute__((aligned(__alignof__(struct inotify_event)))); + ssize_t len = read(live_watch_fd, buf, sizeof(buf)); + + if (len > 0) { + got_event = TRUE; + + /* Check for DELETE_SELF or MOVE_SELF events. */ + char *ptr = buf; + while (ptr < buf + len) { + struct inotify_event *event = (struct inotify_event *)ptr; + + if (event->mask & (IN_DELETE_SELF | IN_MOVE_SELF)) { + /* File was deleted or moved. Re-establish watch if file + * still exists (common with atomic save patterns). */ + struct stat st; + if (stat(openfile->filename, &st) == 0) + rewatch_file(openfile->filename); + else + statusline(ALERT, _("Live: watched file was deleted")); + } + + ptr += sizeof(struct inotify_event) + event->len; + } + } + } +#elif defined(HAVE_KQUEUE) + { + struct kevent event; + struct timespec zero = {0, 0}; + + if (kevent(live_watch_fd, NULL, 0, &event, 1, &zero) > 0) { + got_event = TRUE; + + if (event.fflags & (NOTE_DELETE | NOTE_RENAME)) { + struct stat st; + if (stat(openfile->filename, &st) == 0) + rewatch_file(openfile->filename); + else + statusline(ALERT, _("Live: watched file was deleted")); + } + } + + /* As a safety net (NFS, edge cases), also check mtime. */ + if (!got_event) { + struct stat st; + if (stat(openfile->filename, &st) == 0 && openfile->statinfo != NULL) { + if (st.st_mtime != openfile->statinfo->st_mtime || + st.st_size != openfile->statinfo->st_size) { + got_event = TRUE; + } + } + } + } +#else + /* Stat-based fallback: check mtime. */ + { + struct stat st; + if (stat(openfile->filename, &st) == 0 && openfile->statinfo != NULL) { + if (st.st_mtime != openfile->statinfo->st_mtime || + st.st_size != openfile->statinfo->st_size) { + got_event = TRUE; + } + } + } +#endif + + /* Track pending changes so that events consumed from kqueue (EV_CLEAR) + * are not lost if we are rate-limited at the moment. */ + if (got_event) + change_pending = TRUE; + + if (!change_pending) + return FALSE; + + /* Rate-limit: don't process changes more often than every 100ms. */ + if (!rate_limit_ok()) + return FALSE; + + change_pending = FALSE; + + /* Check if file still exists and is within size limit. */ + struct stat st; + if (stat(openfile->filename, &st) != 0) { + statusline(ALERT, _("Live: file no longer accessible")); + return FALSE; + } + + if (st.st_size > LIVE_MAX_SIZE) { + statusline(ALERT, _("Live: file grew too large, stopping watch")); + close_live_watch(); + return FALSE; + } + + /* Read the new file content. */ + ssize_t new_count = 0; + linestruct *new_disk = read_file_to_lines(openfile->filename, &new_count); + if (new_disk == NULL) { + statusline(ALERT, _("Live: could not read file")); + return FALSE; + } + + /* Remember scroll position to potentially auto-follow. */ + bool was_at_bottom = (openfile->current == openfile->filebot); + + /* Merge changes into the buffer. */ + merge_external_changes(new_disk, new_count); + + /* Free the temporary disk buffer. */ + free_lines(new_disk); + + /* Update stat info. */ +#ifndef NANO_TINY + if (openfile->statinfo == NULL) + openfile->statinfo = nmalloc(sizeof(struct stat)); + if (stat(openfile->filename, openfile->statinfo) != 0) { + free(openfile->statinfo); + openfile->statinfo = NULL; + } +#endif + + /* Auto-follow: if we were at the bottom, go to the new bottom. */ + if (was_at_bottom && ISSET(LIVE_FOLLOW)) { + openfile->current = openfile->filebot; + openfile->current_x = 0; + openfile->placewewant = 0; + adjust_viewport(CENTERING); + } + + refresh_needed = TRUE; + return TRUE; +} + +/* Toggle the auto-follow mode for live watching. */ +void toggle_live_follow(void) +{ + if (!ISSET(LIVE_WATCH)) { + statusline(REMARK, _("Not in live watch mode")); + return; + } + + TOGGLE(LIVE_FOLLOW); + + if (ISSET(LIVE_FOLLOW)) { + statusline(REMARK, _("Live follow: ON (auto-scroll to bottom)")); + openfile->current = openfile->filebot; + openfile->current_x = 0; + openfile->placewewant = 0; + adjust_viewport(CENTERING); + refresh_needed = TRUE; + } else { + statusline(REMARK, _("Live follow: OFF")); + } +} + +/* Jump to the next conflict in the buffer. */ +void goto_next_conflict(void) +{ + if (!ISSET(LIVE_WATCH)) { + statusline(REMARK, _("Not in live watch mode")); + return; + } + + if (openfile->conflict_count == 0) { + statusline(REMARK, _("No conflicts")); + return; + } + + /* Start searching from the line after the current one. */ + linestruct *line = openfile->current->next; + if (line == NULL) + line = openfile->filetop; + + linestruct *start = line; + do { + if (line->has_conflict) { + openfile->current = line; + openfile->current_x = 0; + openfile->placewewant = 0; + adjust_viewport(CENTERING); + refresh_needed = TRUE; + + if (line->remote_version != NULL) + statusline(AHEM, _("Conflict at line %zd -- remote: %s"), + line->lineno, line->remote_version); + else + statusline(AHEM, _("Conflict at line %zd"), line->lineno); + return; + } + line = line->next; + if (line == NULL) + line = openfile->filetop; + } while (line != start); + + statusline(REMARK, _("No more conflicts")); +} + +/* Accept the local (user's) version at the current conflict. */ +void accept_local_conflict(void) +{ + if (!ISSET(LIVE_WATCH)) { + statusline(REMARK, _("Not in live watch mode")); + return; + } + + linestruct *line = openfile->current; + + if (!line->has_conflict) { + statusline(REMARK, _("No conflict on this line")); + return; + } + + line->has_conflict = FALSE; + free(line->remote_version); + line->remote_version = NULL; + openfile->conflict_count--; + + refresh_needed = TRUE; + + if (openfile->conflict_count > 0) + statusline(REMARK, _("Kept local -- %d conflict(s) remaining"), + openfile->conflict_count); + else + statusline(REMARK, _("Kept local -- all conflicts resolved")); +} + +/* Accept the remote (disk) version at the current conflict. */ +void accept_remote_conflict(void) +{ + if (!ISSET(LIVE_WATCH)) { + statusline(REMARK, _("Not in live watch mode")); + return; + } + + linestruct *line = openfile->current; + + if (!line->has_conflict) { + statusline(REMARK, _("No conflict on this line")); + return; + } + + /* Replace the line content with the remote version. */ + if (line->remote_version != NULL) { + if (line->remote_version[0] == '\0' && strcmp(line->remote_version, "") == 0) { + /* Remote says "deleted" -- but we keep the line, just clear it. + * The user can delete it manually if desired. */ + free(line->data); + line->data = copy_of(line->remote_version); + } else { + free(line->data); + line->data = copy_of(line->remote_version); + } + } + + line->has_conflict = FALSE; + line->is_dirty = FALSE; + free(line->remote_version); + line->remote_version = NULL; + openfile->conflict_count--; + +#ifdef ENABLE_COLOR + free(line->multidata); + line->multidata = NULL; +#endif + + refresh_needed = TRUE; + + if (openfile->conflict_count > 0) + statusline(REMARK, _("Took remote -- %d conflict(s) remaining"), + openfile->conflict_count); + else + statusline(REMARK, _("Took remote -- all conflicts resolved")); +} + +/* Initialize live-mode fields on a newly created linestruct. */ +void init_live_line(linestruct *line) +{ + line->is_dirty = FALSE; + line->has_conflict = FALSE; + line->remote_version = NULL; +} + +/* Mark the current line as dirty (called when the user edits it). */ +void mark_line_dirty(linestruct *line) +{ + if (ISSET(LIVE_WATCH) && line != NULL) + line->is_dirty = TRUE; +} + +#endif /* ENABLE_LIVE */ diff --git a/src/nano.c b/src/nano.c index 55feb924..07e150b3 100644 --- a/src/nano.c +++ b/src/nano.c @@ -78,6 +78,11 @@ linestruct *make_new_node(linestruct *prevnode) #ifndef NANO_TINY newnode->has_anchor = FALSE; #endif +#ifdef ENABLE_LIVE + newnode->is_dirty = FALSE; + newnode->has_conflict = FALSE; + newnode->remote_version = NULL; +#endif return newnode; } @@ -110,6 +115,9 @@ void delete_node(linestruct *line) free(line->data); #ifdef ENABLE_COLOR free(line->multidata); +#endif +#ifdef ENABLE_LIVE + free(line->remote_version); #endif free(line); } @@ -156,6 +164,11 @@ linestruct *copy_node(const linestruct *src) #ifndef NANO_TINY dst->has_anchor = src->has_anchor; #endif +#ifdef ENABLE_LIVE + dst->is_dirty = FALSE; + dst->has_conflict = FALSE; + dst->remote_version = NULL; +#endif return dst; } @@ -240,6 +253,11 @@ void restore_terminal(void) /* Exit normally: restore terminal state and report any startup errors. */ void finish(void) { +#ifdef ENABLE_LIVE + if (ISSET(LIVE_WATCH)) + close_live_watch(); +#endif + /* Blank the status bar and (if applicable) the shortcut list. */ blank_statusbar(); blank_bottombars(); @@ -653,6 +671,9 @@ void usage(void) print_opt("-_", "--minibar", N_("Show a feedback bar at the bottom")); print_opt("-0", "--zero", N_("Hide all bars, use whole terminal")); print_opt("-1", "--solosidescroll", N_("Scroll only the current line sideways")); +#endif +#ifdef ENABLE_LIVE + print_opt("", "--live", N_("Monitor the file for external changes")); #endif print_opt("-/", "--modernbindings", N_("Use better-known key bindings")); } @@ -1845,6 +1866,9 @@ int main(int argc, char **argv) {"zero", 0, NULL, '0'}, {"solosidescroll", 0, NULL, '1'}, #endif +#ifdef ENABLE_LIVE + {"live", 0, NULL, 0xCD}, +#endif #ifdef HAVE_LIBMAGIC {"magic", 0, NULL, '!'}, #endif @@ -2158,6 +2182,11 @@ int main(int argc, char **argv) case '1': SET(SOLO_SIDESCROLL); break; +#ifdef ENABLE_LIVE + case 0xCD: + SET(LIVE_WATCH); + break; +#endif default: printf(_("Type '%s -h' for a list of available options.\n"), argv[0]); exit(1); @@ -2655,6 +2684,18 @@ int main(int argc, char **argv) prepare_for_display(); +#ifdef ENABLE_LIVE + /* If --live was requested and we have a real file, start watching it. */ + if (ISSET(LIVE_WATCH) && openfile->filename[0] != '\0') { + char *realname = real_dir_from_tilde(openfile->filename); + if (!init_live_watch(realname)) + UNSET(LIVE_WATCH); + else + statusline(REMARK, _("Live watching: %s"), openfile->filename); + free(realname); + } +#endif + #ifdef ENABLE_NANORC if (startup_problem != NULL) statusline(ALERT, "%s", startup_problem); @@ -2720,6 +2761,12 @@ int main(int argc, char **argv) } #endif +#ifdef ENABLE_LIVE + /* In live watch mode, check for external file changes. */ + if (ISSET(LIVE_WATCH) && live_watch_fd >= 0 && waiting_keycodes() == 0) + check_live_watch(); +#endif + if ((refresh_needed && LINES > 1) || (LINES == 1 && lastmessage <= HUSH)) edit_refresh(); else diff --git a/src/prototypes.h b/src/prototypes.h index 488a6861..b4ca969f 100644 --- a/src/prototypes.h +++ b/src/prototypes.h @@ -662,6 +662,24 @@ void spotlight_softwrapped(size_t from_col, size_t to_col); void do_credits(void); #endif +/* Most functions in live.c. */ +#ifdef ENABLE_LIVE +extern int live_watch_fd; +void take_clean_snapshot(void); +void clear_all_dirty_flags(void); +void clear_all_conflicts(void); +bool init_live_watch(const char *filename); +void rewatch_file(const char *filename); +void close_live_watch(void); +bool check_live_watch(void); +void toggle_live_follow(void); +void goto_next_conflict(void); +void accept_local_conflict(void); +void accept_remote_conflict(void); +void init_live_line(linestruct *line); +void mark_line_dirty(linestruct *line); +#endif + /* These are just name definitions. */ void case_sens_void(void); void regexp_void(void); diff --git a/src/winio.c b/src/winio.c index bd48d853..7512400c 100644 --- a/src/winio.c +++ b/src/winio.c @@ -27,6 +27,10 @@ #include <sys/ioctl.h> #endif #include <string.h> +#ifdef ENABLE_LIVE +#include <sys/select.h> +#include <unistd.h> +#endif #ifdef ENABLE_UTF8 #include <wchar.h> #endif @@ -223,6 +227,13 @@ void read_keys_from(WINDOW *frame) #ifdef NANO_TINY input = wgetch(frame); #else +#ifdef ENABLE_LIVE + /* When in live watch mode, use a per-window timeout so wgetch + * returns ERR periodically, allowing us to check for file changes. */ + if (ISSET(LIVE_WATCH) && live_watch_fd >= 0 && !timed) { + wtimeout(frame, 200); /* 200ms timeout on this window. */ + } +#endif if (!the_window_resized) input = wgetch(frame); if (the_window_resized) { @@ -253,6 +264,25 @@ void read_keys_from(WINDOW *frame) continue; } } + +#ifdef ENABLE_LIVE + /* When the timeout expired (no key pressed), check for file changes. */ + if (input == ERR && ISSET(LIVE_WATCH) && live_watch_fd >= 0) { + /* Restore blocking mode while we process. */ + wtimeout(frame, -1); + + check_live_watch(); + + if (refresh_needed) { + edit_refresh(); + place_the_cursor(); + doupdate(); + } + + /* The timeout will be re-set at the top of the next iteration. */ + continue; + } +#endif #endif /* When we've failed to get a keycode millions of times in a row, * assume our input source is gone and die gracefully. We could @@ -262,6 +292,12 @@ void read_keys_from(WINDOW *frame) die(_("Too many errors from stdin\n")); } +#ifdef ENABLE_LIVE + /* Restore blocking mode now that we have a real keystroke. */ + if (ISSET(LIVE_WATCH) && live_watch_fd >= 0) + wtimeout(frame, -1); +#endif + curs_set(0); /* When there is no keystroke buffer yet, allocate one. */ @@ -2065,6 +2101,12 @@ void titlebar(const char *path) if (ISSET(VIEW_MODE)) state = _("View"); +#ifdef ENABLE_LIVE + else if (ISSET(LIVE_WATCH) && openfile->modified) + state = _("LIVE *"); + else if (ISSET(LIVE_WATCH)) + state = _("LIVE"); +#endif #ifndef NANO_TINY else if (ISSET(STATEFLAGS)) state = "+.xxxxx"; @@ -2871,6 +2913,15 @@ int update_line(linestruct *line, size_t index) if (spotlighted && line == openfile->current) spotlight(light_from_col, light_to_col); +#ifdef ENABLE_LIVE + /* Show a conflict marker at the left edge of conflicted lines. */ + if (line->has_conflict && margin > 0) { + wattron(midwin, interface_color_pair[ERROR_MESSAGE]); + mvwaddch(midwin, row, 0, '!'); + wattroff(midwin, interface_color_pair[ERROR_MESSAGE]); + } +#endif + free(converted); return 1; } -- 2.50.1 (Apple Git-155)