mirror of
https://github.com/SpartanJ/eepp.git
synced 2026-09-22 13:01:05 +03:00
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.
This commit is contained in:
@@ -366,6 +366,9 @@ ecode versucht, externes Terminal zu öffnen.</string>
|
||||
<string name="git_confirm_branch_delete">Sind Sie sicher, dass Sie Zweig "%s" löschen möchten?</string>
|
||||
<string name="git_confirm_branch_merge">Sind Sie sicher, dass Sie aus Zweig "%s" vereinigen möchten?</string>
|
||||
<string name="git_confirm_discard_changes">Sind Sie sicher, dass Sie alle Änderungen verwerfen möchten in: "%s"?</string>
|
||||
<string name="git_confirm_delete_untracked_item">Möchten Sie das folgende nicht versionierte Element wirklich dauerhaft löschen:
|
||||
%s?</string>
|
||||
<string name="git_confirm_delete_untracked_items">Möchten Sie wirklich %d nicht versionierte Elemente dauerhaft löschen?</string>
|
||||
<string name="git_confirm_drop_stash">Sind Sie sicher, dass Sie den ausgewählten Stash fallen lassen möchten?</string>
|
||||
<string name="git_confirm_push_changes">Sind Sie sicher, dass Sie lokale Änderungen gen Remoteserver pushen wollen?</string>
|
||||
<string name="git_create_branch">Zweig erstellen</string>
|
||||
@@ -373,6 +376,7 @@ ecode versucht, externes Terminal zu öffnen.</string>
|
||||
Zweignamen eingeben:</string>
|
||||
<string name="git_create_local_branch">Lokalen Zweig erstellen?</string>
|
||||
<string name="git_delete_branch">Löschen</string>
|
||||
<string name="git_delete_untracked_failed">Die folgenden nicht versionierten Elemente konnten nicht gelöscht werden:</string>
|
||||
<string name="git_diff_head">HEAD-Diff</string>
|
||||
<string name="git_diff_changed">Änderungs-Diff</string>
|
||||
<string name="git_diff_staged">Stage-Diff</string>
|
||||
|
||||
@@ -350,6 +350,9 @@ ecode will try to open an external terminal.</string>
|
||||
<string name="git_confirm_branch_delete">Are you sure you want to delete the branch "%s"?</string>
|
||||
<string name="git_confirm_branch_merge">Are you sure you want to merge from branch "%s"?</string>
|
||||
<string name="git_confirm_discard_changes">Are you sure you want to discard the changes in file: "%s"?</string>
|
||||
<string name="git_confirm_delete_untracked_item">Are you sure you want to permanently delete the untracked item:
|
||||
%s?</string>
|
||||
<string name="git_confirm_delete_untracked_items">Are you sure you want to permanently delete %d untracked items?</string>
|
||||
<string name="git_confirm_drop_stash">Do you want to drop the selected stash?</string>
|
||||
<string name="git_confirm_push_changes">Are you sure you want to push the local changes to the remote server?</string>
|
||||
<string name="git_create_branch">Create Branch</string>
|
||||
@@ -357,6 +360,7 @@ ecode will try to open an external terminal.</string>
|
||||
Enter the name for the branch:</string>
|
||||
<string name="git_create_local_branch">Create local branch?</string>
|
||||
<string name="git_delete_branch">Delete</string>
|
||||
<string name="git_delete_untracked_failed">Could not delete the following untracked items:</string>
|
||||
<string name="git_diff_head">Diff HEAD</string>
|
||||
<string name="git_diff_changed">Diff Changed</string>
|
||||
<string name="git_diff_staged">Diff Staged</string>
|
||||
|
||||
@@ -349,6 +349,9 @@ ecode tentera d'ouvrir un terminal externe.</string>
|
||||
<string name="git_confirm_branch_delete">Êtes-vous sûr de vouloir supprimer la branche "%s" ?</string>
|
||||
<string name="git_confirm_branch_merge">Êtes-vous sûr de vouloir fusionner à partir de la branche "%s" ?</string>
|
||||
<string name="git_confirm_discard_changes">Êtes-vous sûr de vouloir annuler les modifications du fichier: "%s" ?</string>
|
||||
<string name="git_confirm_delete_untracked_item">Êtes-vous sûr de vouloir supprimer définitivement l’élément non suivi :
|
||||
%s ?</string>
|
||||
<string name="git_confirm_delete_untracked_items">Êtes-vous sûr de vouloir supprimer définitivement %d éléments non suivis ?</string>
|
||||
<string name="git_confirm_drop_stash">Voulez-vous supprimer le stash sélectionné ?</string>
|
||||
<string name="git_confirm_push_changes">Êtes-vous sûr de vouloir envoyer les modifications locales vers le serveur distant ?</string>
|
||||
<string name="git_create_branch">Créer une branche</string>
|
||||
@@ -356,6 +359,7 @@ ecode tentera d'ouvrir un terminal externe.</string>
|
||||
Entrez le nom de la branche :</string>
|
||||
<string name="git_create_local_branch">Créer une branche locale ?</string>
|
||||
<string name="git_delete_branch">Supprimer</string>
|
||||
<string name="git_delete_untracked_failed">Impossible de supprimer les éléments non suivis suivants :</string>
|
||||
<string name="git_diff_head">Diff HEAD</string>
|
||||
<string name="git_diff_changed">Diff des modifications</string>
|
||||
<string name="git_diff_staged">Diff Staged</string>
|
||||
|
||||
@@ -271,6 +271,9 @@
|
||||
<string name="git_confirm_branch_delete">Are you sure you want to delete the branch "%s"?</string>
|
||||
<string name="git_confirm_branch_merge">Are you sure you want to merge from branch "%s"?</string>
|
||||
<string name="git_confirm_discard_changes">Are you sure you want to discard the changes in file: "%s"?</string>
|
||||
<string name="git_confirm_delete_untracked_item">确定要永久删除以下未跟踪项吗?
|
||||
%s</string>
|
||||
<string name="git_confirm_delete_untracked_items">确定要永久删除 %d 个未跟踪项吗?</string>
|
||||
<string name="git_confirm_drop_stash">Do you want to drop the selected stash?</string>
|
||||
<string name="git_confirm_push_changes">Are you sure you want to push the local changes to the remote server?</string>
|
||||
<string name="git_create_branch">创建分支</string>
|
||||
@@ -278,6 +281,7 @@
|
||||
Enter the name for the branch:</string>
|
||||
<string name="git_create_local_branch">新建本地分支?</string>
|
||||
<string name="git_delete_branch">删除</string>
|
||||
<string name="git_delete_untracked_failed">无法删除以下未跟踪项:</string>
|
||||
<string name="git_diff_changed">更改差异</string>
|
||||
<string name="git_discard">Discard</string>
|
||||
<string name="git_drop_stash">Drop Stash</string>
|
||||
|
||||
@@ -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 );
|
||||
|
||||
|
||||
@@ -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 <typename Output, typename Input> 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<String>( str );
|
||||
}
|
||||
|
||||
std::string String::escape( std::string_view str ) {
|
||||
return escapeStringSequence<std::string>( 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<unsigned char>( escaped - '0' );
|
||||
for ( int digit = 1;
|
||||
digit < 3 && i + 1 < str.size() && str[i + 1] >= '0' && str[i + 1] <= '7';
|
||||
++digit ) {
|
||||
value = static_cast<unsigned char>( value * 8 + str[++i] - '0' );
|
||||
}
|
||||
output += static_cast<char>( value );
|
||||
break;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
String::HashType String::hash( const std::string& str ) {
|
||||
return String::hash( str.c_str() );
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ void addMarkdown() {
|
||||
{ "Markdown",
|
||||
{ "%.md$", "%.markdown$" },
|
||||
{
|
||||
{ { "```text", "```" }, "string" },
|
||||
{ { "```[%w \t%+%-#]+", "```" }, "function", dynSyntax },
|
||||
{ { "include", "#comments" }, "normal" },
|
||||
{ { "include", "#strings" }, "normal" },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#include "utest.h"
|
||||
#include "utest.hpp"
|
||||
|
||||
#include "../../tools/ecode/plugins/git/git.hpp"
|
||||
#include <chrono>
|
||||
@@ -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<std::string>{ "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" );
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#include "utest.h"
|
||||
#include "utest.hpp"
|
||||
#include <cstdlib>
|
||||
#include <eepp/core/string.hpp>
|
||||
#include <eepp/system/filesystem.hpp>
|
||||
@@ -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" );
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1442,6 +1442,56 @@ void GitPlugin::unstage( const std::vector<std::string>& files ) {
|
||||
runFileOperation( files, FileOperation::Unstage );
|
||||
}
|
||||
|
||||
void GitPlugin::deleteUntrackedFiles( std::vector<std::string> 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<int>( 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<std::string> 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<Uint32>( Git::GitStatusType::Untracked ) ) );
|
||||
} else if ( id == "git-discard-all" ) {
|
||||
auto discardFiles = model->getFiles( repoFullName( repoPath ),
|
||||
static_cast<Uint32>( type ) );
|
||||
@@ -3954,10 +4010,12 @@ void GitPlugin::openFileStatusMenu( std::vector<Git::DiffFile> 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<Git::DiffFile> 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<Git::DiffFile> 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 );
|
||||
|
||||
@@ -314,6 +314,8 @@ class GitPlugin : public PluginBase {
|
||||
|
||||
void unstage( const std::vector<std::string>& files );
|
||||
|
||||
void deleteUntrackedFiles( std::vector<std::string> files );
|
||||
|
||||
enum class FileOperation { Stage, Unstage, Discard, RestoreHead };
|
||||
|
||||
void runFileOperation( std::vector<std::string> files, FileOperation operation );
|
||||
|
||||
Reference in New Issue
Block a user