Added autosave functionality

The autosave functionality is contained in a SaveHistoryAutoTask
class, which inherits from SessionTask. The autosave mechanism
hinges on two pieces of information regarding the autosave file:
the number of bytes used to store the contents of dropped lines
(represented by SaveHistoryAutoTask::_droppedBytes) and a list
of byte offsets corresponding to the start of the contents of lines
on the screen (represented by SaveHistoryAutoTask::_bytesLines).
Everytime a line is dropped, SaveHistoryAutoTask::_droppedBytes
is updated using _bytesLines. Everytime the output is read and
saved to file, the autosave file is resized to _droppedBytes and
the current screen output is appended. Everytime the output is read
or the screen is resized, _bytesLines is updated.

The autosave can be started using an "Auto Save Ouput As" button in
the "File" tab of the toolbar. Once the autosave is started, said
button is replaced by a "Stop Auto Save" button which allows the
user to stop the autosave. Internally, any errors encountered by
the program would result in an KMessageBox reporting the details of
the error and stopping the autosave as well. Clicking on the stop
button reveals the start button again.

Similar to SaveHistoryTask, there is a file dialog which allows the
user to choose which file they would like the autosave contents to
be stored in.

Apart from errors involving reading session output and writing to
file, modifying the file externally, renaming the file and deleting
the file will also result in an error. Emulation::_currentScreen
being changed will also result in an error.

The autosave is conducted at a fixed time interval, apart from an
edge case where an autosave is required immediately due to internal
constraints. Said fixed time interval can be set by the user in the
settings of their current profile, specifically the "Advanced" tab.

Details on the internals of the autosave functionality is documented
in the source files.

GUI
FEATURE: 208620
This commit is contained in:
Theodore Wang
2024-07-21 18:26:27 +00:00
committed by Kurt Hindenburg
parent 3573cfef06
commit ae222d8eb5
16 changed files with 567 additions and 2 deletions

View File

@@ -5,6 +5,8 @@
<MenuBar>
<Menu name="file">
<Action name="file_save_as" group="session-operations"/>
<Action name="file-autosave" group="session-operations"/>
<Action name="stop-autosave" group="session-operations"/>
<Separator group="session-operations"/>
<Action name="file_print" group="session-operations"/>
<Separator group="session-operations"/>

View File

@@ -158,6 +158,7 @@ set(konsoleprivate_SRCS ${windowadaptors_SRCS}
RenameTabDialog.cpp
SSHProcessInfo.cpp
SaveHistoryTask.cpp
SaveHistoryAutoTask.cpp
Screen.cpp
ScreenWindow.cpp
ScrollState.cpp

View File

@@ -129,6 +129,10 @@ void Emulation::setScreenInternal(int index)
void Emulation::clearHistory()
{
if (_currentScreen == _screen[0]) {
Q_EMIT updateDroppedLines(_screen[0]->getHistLines());
}
_screen[0]->setScroll(_screen[0]->getScroll(), false);
}
@@ -269,6 +273,7 @@ void Emulation::showBulk()
_bulkTimer1.stop();
_bulkTimer2.stop();
Q_EMIT updateDroppedLines(_currentScreen->fastDroppedLines() + _currentScreen->droppedLines());
Q_EMIT outputChanged();
_currentScreen->resetScrolledLines();
@@ -331,4 +336,9 @@ QSize Emulation::imageSize() const
return {_currentScreen->getColumns(), _currentScreen->getLines()};
}
QList<int> Emulation::getCurrentScreenCharacterCounts() const
{
return _currentScreen->getCharacterCounts();
}
#include "moc_Emulation.cpp"

View File

@@ -194,6 +194,8 @@ public:
bool programBracketedPasteMode() const;
QList<int> getCurrentScreenCharacterCounts() const;
public Q_SLOTS:
/** Change the size of the emulation's image */
@@ -407,6 +409,13 @@ Q_SIGNALS:
void toggleUrlExtractionRequest();
/**
* Mainly used to communicate dropped lines to active autosave tasks.
* Takes into account lines dropped by Screen::addHistLine and Screen::fastAddHistLine.
* Also includes lines dropped by clearing scrollback and resetting the screen.
*/
void updateDroppedLines(int droppedLines);
protected:
virtual void setMode(int mode) = 0;
virtual void resetMode(int mode) = 0;

259
src/SaveHistoryAutoTask.cpp Normal file
View File

@@ -0,0 +1,259 @@
/*
SPDX-FileCopyrightText: 2024 Theodore Wang <theodorewang12@gmail.com>
SPDX-License-Identifier: GPL-2.0-or-later
*/
#include "SaveHistoryAutoTask.h"
#include <QApplication>
#include <QFileDialog>
#include <QLockFile>
#include <QTextStream>
#include <QDebug>
#include <KConfig>
#include <KConfigGroup>
#include <KLocalizedString>
#include <KMessageBox>
#include <KSharedConfig>
#include "Emulation.h"
#include "session/SessionManager.h"
namespace Konsole
{
QString SaveHistoryAutoTask::_saveDialogRecentURL;
SaveHistoryAutoTask::SaveHistoryAutoTask(QObject *parent)
: SessionTask(parent)
, _droppedBytes(0)
, _bytesLines(0)
, _pendingChanges(false)
{
}
SaveHistoryAutoTask::~SaveHistoryAutoTask() = default;
void SaveHistoryAutoTask::execute()
{
QFileDialog *dialog = new QFileDialog(QApplication::activeWindow());
dialog->setAcceptMode(QFileDialog::AcceptSave);
QStringList mimeTypes{QStringLiteral("text/plain")};
dialog->setMimeTypeFilters(mimeTypes);
KSharedConfigPtr konsoleConfig = KSharedConfig::openConfig();
auto group = konsoleConfig->group(QStringLiteral("SaveHistory Settings"));
if (_saveDialogRecentURL.isEmpty()) {
const auto list = group.readPathEntry("Recent URLs", QStringList());
if (list.isEmpty()) {
dialog->setDirectory(QDir::homePath());
} else {
dialog->setDirectoryUrl(QUrl(list.at(0)));
}
} else {
dialog->setDirectoryUrl(QUrl(_saveDialogRecentURL));
}
Q_ASSERT(sessions().size() == 1);
dialog->setWindowTitle(i18n("Save Output From %1", session()->title(Session::NameRole)));
int result = dialog->exec();
if (result != QDialog::Accepted) {
return;
}
QUrl url = (dialog->selectedUrls()).at(0);
if (!url.isValid()) {
// UI: Can we make this friendlier?
KMessageBox::error(nullptr, i18n("%1 is an invalid URL, the output could not be saved.", url.url()));
return;
}
// Save selected URL for next time
_saveDialogRecentURL = url.adjusted(QUrl::RemoveFilename | QUrl::StripTrailingSlash).toString();
group.writePathEntry("Recent URLs", _saveDialogRecentURL);
const QString path = url.path();
_destinationFile.setFileName(path);
if (!_destinationFile.open(QFile::ReadWrite)) {
KMessageBox::error(nullptr, i18n("Failed to create autosave file at %1.", url.url()));
return;
}
_watcher.addPath(path);
connect(&_watcher, &QFileSystemWatcher::fileChanged, this, &SaveHistoryAutoTask::fileModified);
connect(&_timer, &QTimer::timeout, this, &SaveHistoryAutoTask::linesChanged);
connect(session()->emulation(), &Emulation::outputChanged, this, [&]() {
_pendingChanges = true;
});
connect(session()->emulation(), &Emulation::imageSizeChanged, this, &SaveHistoryAutoTask::imageResized);
connect(session()->emulation(), &Emulation::updateDroppedLines, this, &SaveHistoryAutoTask::linesDropped);
connect(session()->emulation(), &Emulation::primaryScreenInUse, this, [&]() {
KMessageBox::error(nullptr, i18n("Stopping autosave due to switching of screens."));
stop();
});
_timer.setSingleShot(true);
readLines();
dialog->deleteLater();
}
void SaveHistoryAutoTask::fileModified()
{
stop();
KMessageBox::error(nullptr, i18n("Autosave file has been modified externally, preventing further autosaves."));
}
void SaveHistoryAutoTask::linesDropped(int linesDropped)
{
if (linesDropped > 0) {
if (linesDropped > _bytesLines.count()) {
readLines();
}
if (linesDropped == _bytesLines.size()) {
_droppedBytes = _destinationFile.size();
} else {
_droppedBytes = _bytesLines[linesDropped];
}
for (int i = 0; i < linesDropped; ++i) {
_bytesLines.pop_front();
}
}
}
void SaveHistoryAutoTask::stop()
{
Q_EMIT completed(true);
disconnect();
if (autoDelete()) {
deleteLater();
}
}
void SaveHistoryAutoTask::imageResized(int /*rows*/, int /*columns*/)
{
updateByteLineAnchors();
}
void SaveHistoryAutoTask::linesChanged()
{
if (_pendingChanges) {
readLines();
} else {
_timer.start(timerInterval());
}
}
void SaveHistoryAutoTask::readLines()
{
if (session().isNull()) {
stop();
return;
}
_timer.stop();
if (!updateArchive()) {
stop();
KMessageBox::error(nullptr, i18n("Failed to update autosave state on output changes."));
return;
}
updateByteLineAnchors();
_pendingChanges = false;
_timer.start(timerInterval());
}
bool SaveHistoryAutoTask::updateArchive()
{
_watcher.removePath(_destinationFile.fileName());
QTextStream stream(&_destinationFile);
if (!_destinationFile.resize(_droppedBytes) || !stream.seek(_destinationFile.size())) {
return false;
}
_decoder.begin(&stream);
session()->emulation()->writeToStream(&_decoder, 0, session()->emulation()->lineCount() - 1);
_decoder.end();
stream.flush();
if (stream.status() != QTextStream::Ok) {
return false;
}
_watcher.addPath(_destinationFile.fileName());
return true;
}
void SaveHistoryAutoTask::updateByteLineAnchors()
{
if (session().isNull()) {
stop();
return;
}
qint64 currentByte = _droppedBytes;
QList<int> lineLengths = session()->emulation()->getCurrentScreenCharacterCounts();
_bytesLines.resize(lineLengths.size());
_destinationFile.seek(currentByte);
for (int i = 0; i < lineLengths.size(); ++i) {
_bytesLines[i] = currentByte;
QString line;
int lineNumChars = lineLengths[i];
for (int j = 0; j < lineNumChars; ++j) {
char c;
if (!_destinationFile.getChar(&c)) {
qDebug() << "LINE " << (i + 1) << " (" << lineNumChars << ") : " << line;
qDebug() << "INVALID";
return;
}
line.append(QLatin1Char(c));
}
currentByte = _destinationFile.pos();
qDebug() << "LINE " << (i + 1) << " (" << lineNumChars << ") : " << line;
}
if (_destinationFile.atEnd())
qDebug() << "VALID";
else
qDebug() << "INVALID";
}
const QPointer<Session> &SaveHistoryAutoTask::session() const
{
return sessions()[0];
}
int SaveHistoryAutoTask::timerInterval() const
{
return SessionManager::instance()->sessionProfile(session().data())->property<int>(Profile::AutoSaveInterval);
}
}
#include "moc_SaveHistoryAutoTask.cpp"

119
src/SaveHistoryAutoTask.h Normal file
View File

@@ -0,0 +1,119 @@
/*
SPDX-FileCopyrightText: 2024 Theodore Wang <theodorewang12@gmail.com>
SPDX-License-Identifier: GPL-2.0-or-later
*/
#ifndef SAVEHISTORYAUTOTASK_H
#define SAVEHISTORYAUTOTASK_H
#include <QFile>
#include <QFileSystemWatcher>
#include <QTimer>
#include "../decoders/PlainTextDecoder.h"
#include "konsoleprivate_export.h"
#include "session/SessionTask.h"
namespace Konsole
{
/**
* A task which prompts for a URL for each session and saves that session's output
* to the given URL
*/
class KONSOLEPRIVATE_EXPORT SaveHistoryAutoTask : public SessionTask
{
Q_OBJECT
public:
/** Constructs a new task to save session output to URLs */
explicit SaveHistoryAutoTask(QObject *parent = nullptr);
~SaveHistoryAutoTask() override;
/**
* Opens a save file dialog for each session in the group and begins saving
* each session's history to the given URL.
*
* The data transfer is performed asynchronously and will continue after execute() returns.
*/
void execute() override;
public Q_SLOTS:
// Stops the autosave process.
void stop();
private Q_SLOTS:
/**
* Increases _droppedBytes using info in _bytesLines when lines
* have been dropped from the screen and history.
*/
void linesDropped(int linesDropped);
/**
* Resets _bytesLines since the lines on the screen changes when
* the screen is resized.
*/
void imageResized(int rows, int columns);
// Called when Emulation::outputChanged() is emitted.
void linesChanged();
/**
* If the QFileSystemWatcher::fileChanged() signal is emitted,
* this is called which terminates the autosave process with an error.
*/
void fileModified();
private:
// Reads the session output.
void readLines();
bool updateArchive();
// Updates _bytesLines.
void updateByteLineAnchors();
const QPointer<Session> &session() const;
/**
* The number of milliseconds to wait after an autosave before
* checking if another is needed.
*/
int timerInterval() const;
// File object used to store the autosaved contents.
QFile _destinationFile;
/**
* Since the autosave process relies on the files not being modified
* externally, this keeps watch on the autosave destination file.
*/
QFileSystemWatcher _watcher;
// Number of bytes used to accomodate dropped content in the autosave file.
qint64 _droppedBytes;
/**
* A list of byte offsets in _destinationFile.
* Each offset corresponds to the first of a series of bytes
* containing content of a line on the emulation's current screen and history.
*/
QList<qint64> _bytesLines;
PlainTextDecoder _decoder;
// Used to time how often the output should be re-read.
QTimer _timer;
/**
* Determines whether the output is re-read at the end
* of the current timer internal.
*/
bool _pendingChanges;
static QString _saveDialogRecentURL;
};
}
#endif

View File

@@ -62,6 +62,7 @@ Screen::Screen(int lines, int columns)
, _scrolledLines(0)
, _lastScrolledRegion(QRect())
, _droppedLines(0)
, _fastDroppedLines(0)
, _oldTotalLines(0)
, _isResize(false)
, _enableReflowLines(false)
@@ -1338,9 +1339,14 @@ int Screen::droppedLines() const
{
return _droppedLines;
}
int Screen::fastDroppedLines() const
{
return _fastDroppedLines;
}
void Screen::resetDroppedLines()
{
_droppedLines = 0;
_fastDroppedLines = 0;
}
void Screen::resetScrolledLines()
{
@@ -2090,6 +2096,78 @@ Character *Screen::getCharacterBuffer(const int size)
return characterBuffer.data();
}
QList<int> Screen::getCharacterCounts() const
{
QList<int> counts;
int totalLines = _history->getLines() + getLines();
for (int line = 0; line < totalLines; ++line) {
int count = getLineLength(line);
bool lineIsWrapped = false;
Character *characterBuffer = getCharacterBuffer(count - 1);
Q_ASSERT(count >= 0);
if (line < _history->getLines()) {
// safety checks
Q_ASSERT(count <= _history->getLineLen(line));
_history->getCells(line, 0, count, characterBuffer);
// Exclude trailing empty cells from count and don't bother processing them further.
// See the comment on the similar case for screen lines for an explanation.
while (count > 0 && (characterBuffer[count - 1].flags & EF_REAL) == 0) {
count--;
}
if (_history->isWrappedLine(line)) {
lineIsWrapped = true;
}
} else {
int screenLine = line - _history->getLines();
Q_ASSERT(screenLine <= _screenLinesSize);
screenLine = qMin(screenLine, _screenLinesSize);
auto *data = _screenLines[screenLine].data();
int length = _screenLines.at(screenLine).count();
// Exclude trailing empty cells from count and don't bother processing them further.
// This is necessary because a newline gets added to the last line when
// the selection extends beyond the last character (last non-whitespace
// character when TrimTrailingWhitespace is true), so the returned
// count from this function must not include empty cells beyond that
// last character.
while (length > 0 && (data[length - 1].flags & EF_REAL) == 0) {
length--;
}
if (_lineProperties.at(screenLine).flags.f.wrapped == 1) {
lineIsWrapped = true;
}
// count cannot be any greater than length
count = qBound(0, length, count);
}
// If the last character is wide, account for it
if (Character::width(characterBuffer[count - 1].character, _ignoreWcWidth) == 2)
count++;
// When users ask not to preserve the linebreaks, they usually mean:
// `treat LINEBREAK as SPACE, thus joining multiple _lines into
// single line in the same way as 'J' does in VIM.`
if (_blockSelectionMode || !lineIsWrapped) {
++count;
}
counts.append(count);
}
return counts;
}
int Screen::copyLineToStream(int line,
int start,
int count,
@@ -2261,8 +2339,12 @@ void Screen::fastAddHistLine()
// If _history size > max history size it will drop a line from _history.
// We need to verify if we need to remove a URL.
if (removeLine && _escapeSequenceUrlExtractor) {
_escapeSequenceUrlExtractor->historyLinesRemoved(1);
if (removeLine) {
if (_escapeSequenceUrlExtractor) {
_escapeSequenceUrlExtractor->historyLinesRemoved(1);
}
_fastDroppedLines++;
}
// Rotate left + clear the last line
std::rotate(_screenLines.begin(), _screenLines.begin() + 1, _screenLines.end());

View File

@@ -634,6 +634,8 @@ public:
*/
int droppedLines() const;
int fastDroppedLines() const;
/**
* Resets the count of the number of lines dropped from
* the history.
@@ -705,6 +707,8 @@ public:
}
void setIgnoreWcWidth(bool ignore);
QList<int> getCharacterCounts() const;
private:
// copies a line of text from the screen or history into a stream using a
// specified character decoder. Returns the number of lines actually copied,
@@ -804,6 +808,7 @@ private:
QRect _lastScrolledRegion;
int _droppedLines;
int _fastDroppedLines;
int _oldTotalLines;
bool _isResize;

View File

@@ -105,6 +105,8 @@ void Vt102Emulation::clearHistory()
void Vt102Emulation::reset(bool softReset, bool preservePrompt)
{
Q_EMIT updateDroppedLines(_currentScreen->getLines());
// Save the current codec so we can set it later.
// Ideally we would want to use the profile setting
const QTextCodec *currentCodec = codec();

View File

@@ -141,6 +141,7 @@ const std::vector<Profile::PropertyInfo> Profile::DefaultProperties = {
{VerticalLineAtChar, "VerticalLineAtChar", TERMINAL_GROUP, 80},
{PeekPrimaryKeySequence, "PeekPrimaryKeySequence", TERMINAL_GROUP, QString()},
{LineNumbers, "LineNumbers", TERMINAL_GROUP, 0},
{AutoSaveInterval, "AutoSaveInterval", TERMINAL_GROUP, 10000},
// Cursor
{UseCustomCursorColor, "UseCustomCursorColor", CURSOR_GROUP, false},

View File

@@ -423,6 +423,9 @@ public:
* soft hyphen (\u00ad) has wcwidth=1, but should not be displayed per Unicode.
*/
IgnoreWcWidth,
/** (int) Milliseconds interval between autosave activations
*/
AutoSaveInterval,
};
Q_ENUM(Property)

View File

@@ -55,6 +55,7 @@
#include "Emulation.h"
#include "HistorySizeDialog.h"
#include "RenameTabDialog.h"
#include "SaveHistoryAutoTask.h"
#include "SaveHistoryTask.h"
#include "ScreenWindow.h"
#include "SearchHistoryTask.h"
@@ -331,6 +332,10 @@ void SessionController::snapshot()
title = session()->title(Session::NameRole);
}
if (!_autoSaveTask.isNull()) {
title.append(QStringLiteral(" (autosaving)"));
}
QColor color = session()->color();
// use the fallback color if needed
if (!color.isValid()) {
@@ -730,6 +735,14 @@ void SessionController::setupCommonActions()
action->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_S));
#endif
_startAutoSaveAction = collection->addAction(QStringLiteral("file-autosave"), this, &SessionController::autoSaveHistory);
_startAutoSaveAction->setText(i18n("Auto Save Output As..."));
_startAutoSaveAction->setVisible(true);
_stopAutoSaveAction = collection->addAction(QStringLiteral("stop-autosave"), this, &SessionController::stopAutoSaveHistory);
_stopAutoSaveAction->setText(i18n("Stop Auto Save"));
_stopAutoSaveAction->setVisible(false);
action = KStandardAction::print(this, &SessionController::requestPrint, collection);
action->setText(i18n("&Print Screen..."));
collection->setDefaultShortcut(action, Konsole::ACCEL | Qt::Key_P);
@@ -1793,6 +1806,29 @@ void SessionController::scrollBackOptionsChanged(int mode, int lines)
}
}
void SessionController::autoSaveHistory()
{
_autoSaveTask = new SaveHistoryAutoTask(this);
_autoSaveTask->setAutoDelete(true);
_autoSaveTask->addSession(session());
_autoSaveTask->execute();
// Only show the button to start autosave when autosave is not ongoing.
// Only show the button to stop autosave when auytosave is ongoing.
connect(_autoSaveTask, &SaveHistoryAutoTask::completed, this, [&]() {
_startAutoSaveAction->setVisible(true);
_stopAutoSaveAction->setVisible(false);
});
_startAutoSaveAction->setVisible(false);
_stopAutoSaveAction->setVisible(true);
}
void SessionController::stopAutoSaveHistory()
{
_autoSaveTask->stop();
}
void SessionController::saveHistory()
{
SessionTask *task = new SaveHistoryTask(this);

View File

@@ -53,6 +53,7 @@ class TerminalDisplay;
class UrlFilter;
class ColorFilter;
class HotSpot;
class SaveHistoryAutoTask;
/**
* Provides the menu actions to manipulate a single terminal session and view pair.
@@ -277,6 +278,8 @@ private Q_SLOTS:
void findPreviousInHistory();
void updateMenuIconsAccordingToReverseSearchSetting();
void changeSearchMatch();
void autoSaveHistory();
void stopAutoSaveHistory();
void saveHistory();
void showHistoryOptions();
void clearHistory();
@@ -415,6 +418,10 @@ private:
std::unique_ptr<KXMLGUIBuilder> _clientBuilder;
QSharedPointer<HotSpot> _currentHotSpot;
QAction *_startAutoSaveAction;
QAction *_stopAutoSaveAction;
QPointer<SaveHistoryAutoTask> _autoSaveTask;
};
}

View File

@@ -249,6 +249,26 @@
<item row="8" column="1" alignment="Qt::AlignmentFlag::AlignRight">
<widget class="QKeySequenceEdit" name="peekPrimaryWidget"/>
</item>
<item row="9" column="0" alignment="Qt::AlignmentFlag::AlignRight">
<widget class="QLabel" name="autoSaveIntervalLabel">
<property name="text">
<string>Set interval between autosaves:</string>
</property>
</widget>
</item>
<item row="9" column="1" alignment="Qt::AlignmentFlag::AlignRight">
<widget class="QSpinBox" name="autoSaveIntervalWidget">
<property name="maximum">
<number>3600000</number>
</property>
<property name="minimum">
<number>0</number>
</property>
<property name="suffix">
<string> ms</string>
</property>
</widget>
</item>
</layout>
</item>
<item alignment="Qt::AlignmentFlag::AlignRight">

View File

@@ -1987,6 +1987,9 @@ void EditProfileDialog::setupAdvancedPage(const Profile::Ptr &profile)
connect(_advancedUi->urlHintsModifierMeta, &QCheckBox::toggled, this, &EditProfileDialog::updateUrlHintsModifier);
}
_advancedUi->autoSaveIntervalWidget->setValue(profile->property<int>(Profile::AutoSaveInterval));
connect(_advancedUi->autoSaveIntervalWidget, &QSpinBox::valueChanged, this, &EditProfileDialog::setAutoSaveInterval);
// encoding options
auto codecAction = new KCodecAction(this);
codecAction->setCurrentCodec(profile->defaultEncoding());
@@ -2182,6 +2185,11 @@ void EditProfileDialog::toggleFlowControl(bool enable)
updateTempProfileProperty(Profile::FlowControlEnabled, enable);
}
void EditProfileDialog::setAutoSaveInterval(int newVal)
{
updateTempProfileProperty(Profile::AutoSaveInterval, newVal);
}
void EditProfileDialog::peekPrimaryKeySequenceChanged()
{
updateTempProfileProperty(Profile::PeekPrimaryKeySequence, _advancedUi->peekPrimaryWidget->keySequence().toString());

View File

@@ -220,6 +220,7 @@ private Q_SLOTS:
void toggleFlowControl(bool);
void updateUrlHintsModifier(bool);
void toggleReverseUrlHints(bool);
void setAutoSaveInterval(int);
void setDefaultCodec(QTextCodec *);