From 4e3c2d966f0fae6240303b07eefb5e97a17fcfa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Tue, 15 Sep 2026 13:19:24 -0300 Subject: [PATCH] ecode: allow deleting untracked files from Git status - Add a Delete action for untracked files and the Untracked section, with confirmation before permanently removing the selected paths. - Decode Git-quoted status paths using new byte-oriented std::string String::escape() and String::unescape() overloads, preventing quoted or octal-escaped filenames from breaking deletion. - Add translations and regression tests for string escaping and quoted untracked paths. --- bin/assets/i18n/de.xml | 4 ++ bin/assets/i18n/en.xml | 4 ++ bin/assets/i18n/fr.xml | 4 ++ bin/assets/i18n/zh.xml | 4 ++ include/eepp/core/string.hpp | 18 +++++ src/eepp/core/string.cpp | 72 ++++++++++++++++++- src/eepp/ui/doc/languages/markdown.cpp | 1 + src/tests/unit_tests/gitconflict_tests.cpp | 22 +++++- .../unit_tests/stringsoperations_tests.cpp | 15 +++- src/tools/ecode/plugins/git/git.cpp | 19 ++++- src/tools/ecode/plugins/git/gitplugin.cpp | 71 +++++++++++++++++- src/tools/ecode/plugins/git/gitplugin.hpp | 2 + 12 files changed, 227 insertions(+), 9 deletions(-) diff --git a/bin/assets/i18n/de.xml b/bin/assets/i18n/de.xml index 03431ac23..73a0d9918 100644 --- a/bin/assets/i18n/de.xml +++ b/bin/assets/i18n/de.xml @@ -366,6 +366,9 @@ ecode versucht, externes Terminal zu öffnen. Sind Sie sicher, dass Sie Zweig "%s" löschen möchten? Sind Sie sicher, dass Sie aus Zweig "%s" vereinigen möchten? Sind Sie sicher, dass Sie alle Änderungen verwerfen möchten in: "%s"? + Möchten Sie das folgende nicht versionierte Element wirklich dauerhaft löschen: +%s? + Möchten Sie wirklich %d nicht versionierte Elemente dauerhaft löschen? Sind Sie sicher, dass Sie den ausgewählten Stash fallen lassen möchten? Sind Sie sicher, dass Sie lokale Änderungen gen Remoteserver pushen wollen? Zweig erstellen @@ -373,6 +376,7 @@ ecode versucht, externes Terminal zu öffnen. Zweignamen eingeben: Lokalen Zweig erstellen? Löschen + Die folgenden nicht versionierten Elemente konnten nicht gelöscht werden: HEAD-Diff Änderungs-Diff Stage-Diff diff --git a/bin/assets/i18n/en.xml b/bin/assets/i18n/en.xml index 5ef9d1e30..86f9c50bc 100644 --- a/bin/assets/i18n/en.xml +++ b/bin/assets/i18n/en.xml @@ -350,6 +350,9 @@ ecode will try to open an external terminal. Are you sure you want to delete the branch "%s"? Are you sure you want to merge from branch "%s"? Are you sure you want to discard the changes in file: "%s"? + Are you sure you want to permanently delete the untracked item: +%s? + Are you sure you want to permanently delete %d untracked items? Do you want to drop the selected stash? Are you sure you want to push the local changes to the remote server? Create Branch @@ -357,6 +360,7 @@ ecode will try to open an external terminal. Enter the name for the branch: Create local branch? Delete + Could not delete the following untracked items: Diff HEAD Diff Changed Diff Staged diff --git a/bin/assets/i18n/fr.xml b/bin/assets/i18n/fr.xml index f6e850348..771ec4d5f 100644 --- a/bin/assets/i18n/fr.xml +++ b/bin/assets/i18n/fr.xml @@ -349,6 +349,9 @@ ecode tentera d'ouvrir un terminal externe. Êtes-vous sûr de vouloir supprimer la branche "%s" ? Êtes-vous sûr de vouloir fusionner à partir de la branche "%s" ? Êtes-vous sûr de vouloir annuler les modifications du fichier: "%s" ? + Êtes-vous sûr de vouloir supprimer définitivement l’élément non suivi : +%s ? + Êtes-vous sûr de vouloir supprimer définitivement %d éléments non suivis ? Voulez-vous supprimer le stash sélectionné ? Êtes-vous sûr de vouloir envoyer les modifications locales vers le serveur distant ? Créer une branche @@ -356,6 +359,7 @@ ecode tentera d'ouvrir un terminal externe. Entrez le nom de la branche : Créer une branche locale ? Supprimer + Impossible de supprimer les éléments non suivis suivants : Diff HEAD Diff des modifications Diff Staged diff --git a/bin/assets/i18n/zh.xml b/bin/assets/i18n/zh.xml index d91d42f44..03873377e 100644 --- a/bin/assets/i18n/zh.xml +++ b/bin/assets/i18n/zh.xml @@ -271,6 +271,9 @@ Are you sure you want to delete the branch "%s"? Are you sure you want to merge from branch "%s"? Are you sure you want to discard the changes in file: "%s"? + 确定要永久删除以下未跟踪项吗? +%s + 确定要永久删除 %d 个未跟踪项吗? Do you want to drop the selected stash? Are you sure you want to push the local changes to the remote server? 创建分支 @@ -278,6 +281,7 @@ Enter the name for the branch: 新建本地分支? 删除 + 无法删除以下未跟踪项: 更改差异 Discard Drop Stash diff --git a/include/eepp/core/string.hpp b/include/eepp/core/string.hpp index ec5f26bb5..0f1700bcb 100644 --- a/include/eepp/core/string.hpp +++ b/include/eepp/core/string.hpp @@ -112,9 +112,27 @@ class EE_API String { /** Escape string sequence */ static String escape( const String& str ); + /** Escape byte string sequence */ + static std::string escape( std::string_view str ); + + static std::string escape( const std::string& str ) { + return escape( std::string_view{ str } ); + } + + static std::string escape( const char* str ) { return escape( std::string_view{ str } ); } + /** Unescape string sequence */ static String unescape( const String& str ); + /** Unescape byte string sequence */ + static std::string unescape( std::string_view str ); + + static std::string unescape( const std::string& str ) { + return unescape( std::string_view{ str } ); + } + + static std::string unescape( const char* str ) { return unescape( std::string_view{ str } ); } + /** @return string hash */ static String::HashType hash( const std::string& str ); diff --git a/src/eepp/core/string.cpp b/src/eepp/core/string.cpp index d29519981..8fc2855d6 100644 --- a/src/eepp/core/string.cpp +++ b/src/eepp/core/string.cpp @@ -614,9 +614,10 @@ Int64 String::BMH::find( std::string_view haystack, std::string_view needle, return find( haystack, needle, haystackOffset, occ, caseInsensitive ); } -String String::escape( const String& str ) { - String output; - for ( size_t i = 0; i < str.size(); i++ ) { +template static Output escapeStringSequence( const Input& str ) { + Output output; + output.reserve( str.size() ); + for ( size_t i = 0; i < str.size(); ++i ) { switch ( str[i] ) { case '\r': output += "\\r"; @@ -648,6 +649,14 @@ String String::escape( const String& str ) { return output; } +String String::escape( const String& str ) { + return escapeStringSequence( str ); +} + +std::string String::escape( std::string_view str ) { + return escapeStringSequence( str ); +} + String String::unescape( const String& str ) { String output; bool lastWasEscape = false; @@ -770,6 +779,63 @@ String String::unescape( const String& str ) { return output; } +std::string String::unescape( std::string_view str ) { + std::string output; + output.reserve( str.size() ); + for ( size_t i = 0; i < str.size(); ++i ) { + if ( str[i] != '\\' || i + 1 >= str.size() ) { + output += str[i]; + continue; + } + + const char escaped = str[++i]; + switch ( escaped ) { + case '\\': + case '\'': + case '"': + case '?': + output += escaped; + break; + case 'r': + output += '\r'; + break; + case 't': + output += '\t'; + break; + case 'n': + output += '\n'; + break; + case 'a': + output += '\a'; + break; + case 'b': + output += '\b'; + break; + case 'f': + output += '\f'; + break; + case 'v': + output += '\v'; + break; + default: + if ( escaped < '0' || escaped > '7' ) { + output += '\\'; + output += escaped; + break; + } + unsigned char value = static_cast( escaped - '0' ); + for ( int digit = 1; + digit < 3 && i + 1 < str.size() && str[i + 1] >= '0' && str[i + 1] <= '7'; + ++digit ) { + value = static_cast( value * 8 + str[++i] - '0' ); + } + output += static_cast( value ); + break; + } + } + return output; +} + String::HashType String::hash( const std::string& str ) { return String::hash( str.c_str() ); } diff --git a/src/eepp/ui/doc/languages/markdown.cpp b/src/eepp/ui/doc/languages/markdown.cpp index 4461d89df..79198f34a 100644 --- a/src/eepp/ui/doc/languages/markdown.cpp +++ b/src/eepp/ui/doc/languages/markdown.cpp @@ -15,6 +15,7 @@ void addMarkdown() { { "Markdown", { "%.md$", "%.markdown$" }, { + { { "```text", "```" }, "string" }, { { "```[%w \t%+%-#]+", "```" }, "function", dynSyntax }, { { "include", "#comments" }, "normal" }, { { "include", "#strings" }, "normal" }, diff --git a/src/tests/unit_tests/gitconflict_tests.cpp b/src/tests/unit_tests/gitconflict_tests.cpp index 4ec037a0f..c017ea485 100644 --- a/src/tests/unit_tests/gitconflict_tests.cpp +++ b/src/tests/unit_tests/gitconflict_tests.cpp @@ -1,4 +1,4 @@ -#include "utest.h" +#include "utest.hpp" #include "../../tools/ecode/plugins/git/git.hpp" #include @@ -25,6 +25,26 @@ struct GitTempDirectory { } // namespace +UTEST( GitStatus, DecodesQuotedUntrackedPaths ) { + const std::string gitPath = Sys::which( "git" ); + if ( gitPath.empty() ) + UTEST_SKIP( "Git is not installed" ); + + GitTempDirectory temp; + Git git( temp.path.string(), gitPath ); + std::string output; + ASSERT_EQ( EXIT_SUCCESS, + git.git( std::vector{ "init" }, temp.path.string(), output ) ); + const std::string relativePath = "Untitled 1--conversation doc.yjs"; + ASSERT_TRUE( FileSystem::fileWrite( ( temp.path / relativePath ).string(), "test" ) ); + + auto status = git.status( false, temp.path.string() ); + ASSERT_EQ( 1u, status.files.size() ); + ASSERT_EQ( 1u, status.files.begin()->second.size() ); + EXPECT_STDSTREQ( relativePath, status.files.begin()->second.front().file ); + EXPECT_EQ( Git::GitStatusType::Untracked, status.files.begin()->second.front().report.type ); +} + UTEST( GitConflict, ParsesNulDelimitedStageRecordsAndUnusualPaths ) { if ( Sys::which( "git" ).empty() ) UTEST_SKIP( "Git is not installed" ); diff --git a/src/tests/unit_tests/stringsoperations_tests.cpp b/src/tests/unit_tests/stringsoperations_tests.cpp index 1c0f10770..49a4fa3f6 100644 --- a/src/tests/unit_tests/stringsoperations_tests.cpp +++ b/src/tests/unit_tests/stringsoperations_tests.cpp @@ -1,4 +1,4 @@ -#include "utest.h" +#include "utest.hpp" #include #include #include @@ -109,6 +109,19 @@ UTEST( String, reusableFormattingAndUtf8Assignment ) { EXPECT_EQ( utf8Storage, reusableUtf8.data() ); } +UTEST( String, byteStringEscapeAndUnescape ) { + const std::string raw = "line one\r\nline two\t\a\b\f\v"; + const std::string escaped = String::escape( raw ); + EXPECT_STDSTREQ( "line one\\r\\nline two\\t\\a\\b\\f\\v", escaped ); + const std::string unescaped = String::unescape( escaped ); + EXPECT_STDSTREQ( raw, unescaped ); + const std::string quoted = String::unescape( R"(quoted\" slash\\ unknown\q)" ); + EXPECT_STDSTREQ( "quoted\" slash\\ unknown\\q", quoted ); + + const std::string utf8Bytes = String::unescape( std::string_view{ R"(\303\261)" } ); + EXPECT_STDSTREQ( "ñ", utf8Bytes ); +} + UTEST( FileSystem, fileCountLines ) { std::string path = Sys::getTempPath() + "eepp_test_count_lines.txt"; FileSystem::fileWrite( path, "A\nB\nC" ); diff --git a/src/tools/ecode/plugins/git/git.cpp b/src/tools/ecode/plugins/git/git.cpp index 1c75c5105..d844143fa 100644 --- a/src/tools/ecode/plugins/git/git.cpp +++ b/src/tools/ecode/plugins/git/git.cpp @@ -1300,6 +1300,19 @@ std::string Git::repoPath( const std::string& file ) { return mProjectPath; } +static void appendDecodedGitPath( std::string& decoded, std::string_view path ) { + if ( path.size() < 2 || path.front() != '"' || path.back() != '"' ) { + decoded.append( path ); + return; + } + + auto unescaped = String::unescape( path.substr( 1, path.size() - 2 ) ); + if ( decoded.empty() ) + decoded = std::move( unescaped ); + else + decoded += unescaped; +} + Git::Result Git::gitSimple( const std::string& cmd, const std::string& projectDir ) { std::string buf; int retCode = git( cmd, projectDir, buf ); @@ -1381,7 +1394,8 @@ Git::Status Git::status( bool recurseSubmodules, const std::string& projectDir ) file = file.substr( rranges[1].start, rranges[1].end - rranges[1].start ); } - std::string filePath = subModulePath + file; + std::string filePath{ subModulePath }; + appendDecodedGitPath( filePath, file ); auto repo = repoName( filePath, false, projectDir ); auto repoIt = s.files.find( repo ); bool found = false; @@ -1449,7 +1463,8 @@ Git::Status Git::status( bool recurseSubmodules, const std::string& projectDir ) file.substr( matches[3].start, matches[3].end - matches[3].start ); } - auto filePath = subModulePath + file; + std::string filePath{ subModulePath }; + appendDecodedGitPath( filePath, file ); auto repo = repoName( filePath, false, projectDir ); auto repoIt = s.files.find( repo ); GitStatusReport status = { GitStatus::NotSet, GitStatusType::Untracked, diff --git a/src/tools/ecode/plugins/git/gitplugin.cpp b/src/tools/ecode/plugins/git/gitplugin.cpp index 8e72b9b52..5656c2bff 100644 --- a/src/tools/ecode/plugins/git/gitplugin.cpp +++ b/src/tools/ecode/plugins/git/gitplugin.cpp @@ -1442,6 +1442,56 @@ void GitPlugin::unstage( const std::vector& files ) { runFileOperation( files, FileOperation::Unstage ); } +void GitPlugin::deleteUntrackedFiles( std::vector files ) { + std::string projectPath = this->projectPath(); + if ( files.empty() || projectPath.empty() ) + return; + + String message = + files.size() == 1 + ? String::fromUtf8( String::format( + i18n( "git_confirm_delete_untracked_item", + "Are you sure you want to permanently delete the untracked item:\n%s?" ) + .toUtf8(), + files.front() ) ) + : String::fromUtf8( String::format( + i18n( "git_confirm_delete_untracked_items", + "Are you sure you want to permanently delete %d untracked items?" ) + .toUtf8(), + static_cast( files.size() ) ) ); + std::string failureMessage = + i18n( "git_delete_untracked_failed", "Could not delete the following untracked items:" ) + .toUtf8(); + UIMessageBox* msgBox = UIMessageBox::New( UIMessageBox::OK_CANCEL, message ); + msgBox->on( + Event::OnConfirm, [this, files = std::move( files ), projectPath = std::move( projectPath ), + failureMessage = std::move( failureMessage )]( const Event* ) mutable { + runAsync( + [files = std::move( files ), projectPath = std::move( projectPath ), + failureMessage = std::move( failureMessage )]() { + Git::Result result; + for ( const auto& file : files ) { + const std::string path = isPath( file ) ? file : projectPath + file; + FileInfo info( path, true ); + const bool removed = info.isDirectory() ? FileSystem::dirRemoveAll( path ) + : FileSystem::fileRemove( path ); + if ( !removed ) { + result.returnCode = EXIT_FAILURE; + if ( result.result.empty() ) + result.result = failureMessage; + result.result += "\n" + file; + } + } + return result; + }, + true, false, false, false, true ); + } ); + msgBox->setCloseShortcut( { KEY_ESCAPE, KEYMOD_NONE } ); + msgBox->setTitle( i18n( "delete", "Delete" ) ); + msgBox->center(); + msgBox->showWhenReady(); +} + void GitPlugin::runFileOperation( std::vector files, FileOperation operation ) { if ( files.empty() ) return; @@ -3723,6 +3773,8 @@ void GitPlugin::buildSidePanelTab() { type == Git::GitStatusType::Changed ) menuAdd( menu, "git-stage-all", i18n( "git_stage_all", "Stage All" ), "diff-added" ); + if ( type == Git::GitStatusType::Untracked ) + menuAdd( menu, "git-delete-untracked", i18n( "delete", "Delete" ) ); if ( type == Git::GitStatusType::Changed ) { menuAdd( menu, "git-diff-changed", @@ -3750,6 +3802,10 @@ void GitPlugin::buildSidePanelTab() { } else if ( id == "git-unstage-all" ) { unstage( model->getFiles( repoFullName( repoPath ), (Uint32)Git::GitStatusType::Staged ) ); + } else if ( id == "git-delete-untracked" ) { + deleteUntrackedFiles( model->getFiles( + repoFullName( repoPath ), + static_cast( Git::GitStatusType::Untracked ) ) ); } else if ( id == "git-discard-all" ) { auto discardFiles = model->getFiles( repoFullName( repoPath ), static_cast( type ) ); @@ -3954,10 +4010,12 @@ void GitPlugin::openFileStatusMenu( std::vector files ) { bool hasStaged = false; bool hasUnstaged = false; bool hasUnmerged = false; + bool allUntracked = true; for ( const auto& file : files ) { hasStaged |= file.report.type == Git::GitStatusType::Staged; hasUnstaged |= file.report.type != Git::GitStatusType::Staged; hasUnmerged |= file.report.type == Git::GitStatusType::Unmerged; + allUntracked &= file.report.type == Git::GitStatusType::Untracked; } if ( hasUnmerged ) { if ( !multiple ) { @@ -4003,14 +4061,19 @@ void GitPlugin::openFileStatusMenu( std::vector files ) { if ( hasStaged ) menuAdd( menu, "git-unstage", i18n( "git_unstage", "Unstage" ), "diff-removed" ); - menu->addSeparator(); - const bool hasDiscardable = std::any_of( files.begin(), files.end(), []( const auto& file ) { return file.report.type == Git::GitStatusType::Changed; } ); + + if ( hasDiscardable || allUntracked ) + menu->addSeparator(); + if ( hasDiscardable ) menuAdd( menu, "git-discard", i18n( "git_discard", "Discard" ) ); + if ( allUntracked ) + menuAdd( menu, "git-delete-untracked", i18n( "delete", "Delete" ) ); + menu->on( Event::OnItemClicked, [this, files = std::move( files )]( const Event* event ) mutable { if ( !mGit ) @@ -4037,6 +4100,10 @@ void GitPlugin::openFileStatusMenu( std::vector files ) { discard( paths.front() ); else discard( paths ); + } else if ( id == "git-delete-untracked" ) { + for ( const auto& file : files ) + paths.emplace_back( file.file ); + deleteUntrackedFiles( std::move( paths ) ); } else if ( id == "git-open-file" ) { for ( const auto& file : files ) openFile( file.file ); diff --git a/src/tools/ecode/plugins/git/gitplugin.hpp b/src/tools/ecode/plugins/git/gitplugin.hpp index 69a8c22cb..be7fcb14c 100644 --- a/src/tools/ecode/plugins/git/gitplugin.hpp +++ b/src/tools/ecode/plugins/git/gitplugin.hpp @@ -314,6 +314,8 @@ class GitPlugin : public PluginBase { void unstage( const std::vector& files ); + void deleteUntrackedFiles( std::vector files ); + enum class FileOperation { Stage, Unstage, Discard, RestoreHead }; void runFileOperation( std::vector files, FileOperation operation );