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:
Martín Lucas Golini
2026-09-15 13:19:24 -03:00
parent 50a0594038
commit 4e3c2d966f
12 changed files with 227 additions and 9 deletions
+17 -2
View File
@@ -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,
+69 -2
View File
@@ -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 );