From 70be74e80d72f8aeb7de97674c6b55492f900cbd Mon Sep 17 00:00:00 2001 From: outfoxxed Date: Fri, 6 Dec 2024 01:18:31 -0800 Subject: [PATCH] io/fileview: add write support FileView is now getting somewhat out of hand. The asynchronous parts especially need to be redone, but this will work for now. --- src/io/FileView.qml | 7 +- src/io/fileview.cpp | 479 ++++++++++++++++++++++++++++++++++---------- src/io/fileview.hpp | 179 +++++++++++++++-- 3 files changed, 541 insertions(+), 124 deletions(-) diff --git a/src/io/FileView.qml b/src/io/FileView.qml index 97e99c4..05f4c7b 100644 --- a/src/io/FileView.qml +++ b/src/io/FileView.qml @@ -4,11 +4,13 @@ FileViewInternal { property bool preload: this.__preload; property bool blockLoading: this.__blockLoading; property bool blockAllReads: this.__blockAllReads; + property bool printErrors: this.__printErrors; property string path: this.__path; onPreloadChanged: this.__preload = preload; onBlockLoadingChanged: this.__blockLoading = this.blockLoading; onBlockAllReadsChanged: this.__blockAllReads = this.blockAllReads; + onPrintErrorsChanged: this.__printErrors = this.printErrors; // Unfortunately path can't be kept as an empty string until the file loads // without using QQmlPropertyValueInterceptor which is private. If we lean fully @@ -16,6 +18,7 @@ FileViewInternal { onPathChanged: { if (!this.preload) this.__preload = false; + this.__printErrors = this.printErrors; this.__path = this.path; if (this.preload) this.__preload = true; } @@ -30,16 +33,18 @@ FileViewInternal { if (!this.preload) this.__preload = false; this.__blockLoading = this.blockLoading; this.__blockAllReads = this.blockAllReads; + this.__printErrors = this.printErrors; this.__path = this.path; const text = this.__text; if (this.preload) this.__preload = true; return text; } - function data(): string { + function data(): var { if (!this.preload) this.__preload = false; this.__blockLoading = this.blockLoading; this.__blockAllReads = this.blockAllReads; + this.__printErrors = this.printErrors; this.__path = this.path; const data = this.__data; if (this.preload) this.__preload = true; diff --git a/src/io/fileview.cpp b/src/io/fileview.cpp index 063ea83..3080075 100644 --- a/src/io/fileview.cpp +++ b/src/io/fileview.cpp @@ -2,6 +2,9 @@ #include #include +#include +#include +#include #include #include #include @@ -9,6 +12,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -19,105 +25,271 @@ namespace qs::io { Q_LOGGING_CATEGORY(logFileView, "quickshell.io.fileview", QtWarningMsg); -FileViewReader::FileViewReader(QString path, bool doStringConversion) - : doStringConversion(doStringConversion) { - this->state.path = std::move(path); - this->setAutoDelete(false); - this->blockMutex.lock(); +QString FileViewError::toString(FileViewError::Enum value) { + switch (value) { + case Success: return "Success"; + case Unknown: return "An unknown error has occurred"; + case FileNotFound: return "The specified file does not exist"; + case PermissionDenied: return "Permission denied"; + case NotAFile: return "The specified path was not a file"; + default: return "Invalid error"; + } } -void FileViewReader::run() { - FileViewReader::read(this->state, this->doStringConversion); +bool FileViewData::operator==(const FileViewData& other) const { + if (this->data == other.data && !this->data.isEmpty()) return true; + if (this->text == other.text && !this->text.isEmpty()) return true; + return this->operator const QByteArray&() == other.operator const QByteArray&(); +} - this->blockMutex.unlock(); - QMetaObject::invokeMethod(this, &FileViewReader::finished, Qt::QueuedConnection); +bool FileViewData::isEmpty() const { return this->data.isEmpty() && this->text.isEmpty(); } + +FileViewData::operator const QString&() const { + if (this->text.isEmpty() && !this->data.isEmpty()) { + this->text = QString::fromUtf8(this->data); + } + + return this->text; +} + +FileViewData::operator const QByteArray&() const { + if (this->data.isEmpty() && !this->text.isEmpty()) { + this->data = this->text.toUtf8(); + } + + return this->data; +} + +FileViewOperation::FileViewOperation(FileView* owner): owner(owner) { + this->setAutoDelete(false); + this->blockMutex.lock(); } -void FileViewReader::block() { +void FileViewOperation::block() { // block until a lock can be acauired, then immediately drop it auto unused = QMutexLocker(&this->blockMutex); } -void FileViewReader::finished() { +void FileViewOperation::tryCancel() { this->shouldCancel.storeRelease(true); } + +void FileViewOperation::finishRun() { + this->blockMutex.unlock(); + QMetaObject::invokeMethod(this, &FileViewOperation::finished, Qt::QueuedConnection); +} + +void FileViewOperation::finished() { emit this->done(); + // Delete happens on the main thread, after done(), meaning no operation accesses + // will be a UAF. delete this; } -void FileViewReader::read(FileViewState& state, bool doStringConversion) { - { - qCDebug(logFileView) << "Reader started for" << state.path; +void FileViewReader::run() { + if (!this->shouldCancel) { + FileViewReader::read(this->owner, this->state, this->doStringConversion, this->shouldCancel); - auto info = QFileInfo(state.path); - state.exists = info.exists(); + if (this->shouldCancel.loadAcquire()) { + qCDebug(logFileView) << "Read" << this << "of" << state.path << "canceled for" << this->owner; + } + } + + this->finishRun(); +} - if (!state.exists) return; +void FileViewReader::read( + FileView* view, + FileViewState& state, + bool doStringConversion, + const QAtomicInteger& shouldCancel +) { + qCDebug(logFileView) << "Reader started for" << state.path; - if (!info.isFile()) { - qCCritical(logFileView) << state.path << "is not a file."; - goto error; - } else if (!info.isReadable()) { - qCCritical(logFileView) << "No permission to read" << state.path; - state.error = true; - goto error; + auto info = QFileInfo(state.path); + state.exists = info.exists(); + + if (!state.exists) { + if (state.printErrors) { + qmlWarning(view) << "Read of " << state.path << " failed: File does not exist."; } - auto file = QFile(state.path); + state.error = FileViewError::FileNotFound; + return; + } + + if (!info.isFile()) { + if (state.printErrors) { + qmlWarning(view) << "Read of " << state.path << " failed: Not a file."; + } - if (!file.open(QFile::ReadOnly)) { - qCCritical(logFileView) << "Failed to open" << state.path; - goto error; + state.error = FileViewError::NotAFile; + return; + } else if (!info.isReadable()) { + if (state.printErrors) { + qmlWarning(view) << "Read of " << state.path << " failed: Permission denied."; } - auto& data = state.data; - if (file.size() != 0) { - data = QByteArray(file.size(), Qt::Uninitialized); - qint64 i = 0; + state.error = FileViewError::PermissionDenied; + return; + } + + if (shouldCancel.loadAcquire()) return; + + auto file = QFile(state.path); + + if (!file.open(QFile::ReadOnly)) { + qmlWarning(view) << "Read of " << state.path << " failed: Unknown failure when opening file."; + state.error = FileViewError::Unknown; + return; + } - while (true) { - auto r = file.read(data.data() + i, data.length() - i); // NOLINT + if (shouldCancel.loadAcquire()) return; - if (r == -1) { - qCCritical(logFileView) << "Failed to read" << state.path; - goto error; - } else if (r == 0) { - data.resize(i); - break; - } + if (file.size() != 0) { + auto data = QByteArray(file.size(), Qt::Uninitialized); + qint64 i = 0; - i += r; + while (true) { + if (shouldCancel.loadAcquire()) return; + + auto r = file.read(data.data() + i, data.length() - i); // NOLINT + + if (r == -1) { + qmlWarning(view) << "Read of " << state.path << " failed: read() failed."; + + state.error = FileViewError::Unknown; + return; + } else if (r == 0) { + data.resize(i); + break; } - } else { - auto buf = std::array(); - - while (true) { - auto r = file.read(buf.data(), buf.size()); // NOLINT - - if (r == -1) { - qCCritical(logFileView) << "Failed to read" << state.path; - goto error; - } else { - data.append(buf.data(), r); - if (r == 0) break; - } + + i += r; + } + + state.data = data; + } else { // Mostly happens in /proc and friends, which have zero sized files with content. + QByteArray data; + auto buf = std::array(); + + while (true) { + if (shouldCancel.loadAcquire()) return; + + auto r = file.read(buf.data(), buf.size()); // NOLINT + + if (r == -1) { + qmlWarning(view) << "Read of " << state.path << " failed: read() failed."; + + state.error = FileViewError::Unknown; + return; + } else { + data.append(buf.data(), r); + if (r == 0) break; } } - if (doStringConversion) { - state.text = QString::fromUtf8(state.data); - state.textDirty = false; - } else { - state.textDirty = true; + state.data = data; + } + + if (shouldCancel.loadAcquire()) return; + + if (doStringConversion) { + state.data.operator const QString&(); + } +} + +void FileViewWriter::run() { + if (!this->shouldCancel.loadAcquire()) { + FileViewWriter::write(this->owner, this->state, this->doAtomicWrite, this->shouldCancel); + + if (this->shouldCancel.loadAcquire()) { + qCDebug(logFileView) << "Write" << this << "of" << state.path << "canceled for" + << this->owner; } + } + this->finishRun(); +} + +void FileViewWriter::write( + FileView* view, + FileViewState& state, + bool doAtomicWrite, + const QAtomicInteger& shouldCancel +) { + qCDebug(logFileView) << "Writer started for" << state.path; + + auto info = QFileInfo(state.path); + state.exists = info.exists(); + + if (!state.exists) { + auto dir = info.dir(); + if (!dir.mkpath(".")) { + if (state.printErrors) { + qmlWarning(view) << "Write of " << state.path + << " failed: Could not create parent directories of file."; + } + + state.error = FileViewError::PermissionDenied; + return; + } + } else if (!info.isWritable()) { + if (state.printErrors) { + qmlWarning(view) << "Write of " << state.path << " failed: Permission denied."; + } + + state.error = FileViewError::PermissionDenied; return; } -error: - state.error = true; + if (shouldCancel.loadAcquire()) return; + + QScopedPointer file; + if (doAtomicWrite) { + file.reset(new QSaveFile(state.path)); + } else { + file.reset(new QFile(state.path)); + } + + if (!file->open(QFile::WriteOnly)) { + qmlWarning(view) << "Write of " << state.path << " failed: Unknown error when opening file."; + state.error = FileViewError::Unknown; + return; + } + + if (shouldCancel.loadAcquire()) return; + + const QByteArray& data = state.data; + qint64 i = 0; + + while (true) { + if (shouldCancel.loadAcquire()) return; + + auto r = file->write(data.data() + i, data.length() - i); // NOLINT + + if (r == -1) { + qmlWarning(view) << "Write of " << state.path << " failed: write() failed."; + + state.error = FileViewError::Unknown; + return; + } else { + i += r; + if (i == data.length()) break; + } + } + + if (shouldCancel.loadAcquire()) return; + + if (doAtomicWrite) { + qDebug() << "Atomic commit"; + if (!reinterpret_cast(file.get())->commit()) { // NOLINT + qmlWarning(view) << "Write of " << state.path << " failed: Atomic commit failed."; + } + } } void FileView::loadAsync(bool doStringConversion) { - if (!this->reader || this->pathInFlight != this->targetPath) { + // Writes update via operationFinished, making a read both invalid and outdated. + if (!this->liveOperation || this->pathInFlight != this->targetPath) { this->cancelAsync(); this->pathInFlight = this->targetPath; @@ -126,40 +298,92 @@ void FileView::loadAsync(bool doStringConversion) { this->updateState(state); } else { qCDebug(logFileView) << "Starting async load for" << this << "of" << this->targetPath; - this->reader = new FileViewReader(this->targetPath, doStringConversion); - QObject::connect(this->reader, &FileViewReader::done, this, &FileView::readerFinished); - QThreadPool::globalInstance()->start(this->reader); // takes ownership + auto* reader = new FileViewReader(this, doStringConversion); + reader->state.path = this->targetPath; + reader->state.printErrors = this->bPrintErrors; + QObject::connect(reader, &FileViewOperation::done, this, &FileView::operationFinished); + QThreadPool::globalInstance()->start(reader); // takes ownership + this->liveOperation = reader; } } } +void FileView::saveAsync() { + if (this->targetPath.isEmpty()) { + qmlWarning(this) << "Cannot write file, as no path has been specified."; + this->writeData = FileViewData(); + } else { + // cancel will blank the data if waiting + auto data = this->writeData; + + this->cancelAsync(); + + qCDebug(logFileView) << "Starting async save for" << this << "of" << this->targetPath; + auto* writer = new FileViewWriter(this, this->bAtomicWrites); + writer->state.path = this->targetPath; + writer->state.data = std::move(data); + writer->state.printErrors = this->bPrintErrors; + QObject::connect(writer, &FileViewOperation::done, this, &FileView::operationFinished); + QThreadPool::globalInstance()->start(writer); // takes ownership + this->liveOperation = writer; + } +} + void FileView::cancelAsync() { - if (this->reader) { + if (!this->liveOperation) return; + this->liveOperation->tryCancel(); + + if (this->liveReader()) { qCDebug(logFileView) << "Disowning async read for" << this; - QObject::disconnect(this->reader, nullptr, this, nullptr); - this->reader = nullptr; + QObject::disconnect(this->liveOperation, nullptr, this, nullptr); + this->liveOperation = nullptr; + } else if (this->liveWriter()) { + // We don't want to start a read or write operation in the middle of a write. + // This really shouldn't block but it isn't worth fixing for now. + qCDebug(logFileView) << "Blocking on write for" << this; + this->waitForJob(); } } -void FileView::readerFinished() { - if (this->sender() != this->reader) { - qCWarning(logFileView) << "got read finished from dropped FileViewReader" << this->sender(); +void FileView::operationFinished() { + if (this->sender() != this->liveOperation) { + qCWarning(logFileView) << "got operation finished from dropped operation" << this->sender(); return; } - qCDebug(logFileView) << "Async load finished for" << this; - this->updateState(this->reader->state); - this->reader = nullptr; + qCDebug(logFileView) << "Async operation finished for" << this; + this->writeData = FileViewData(); + this->updateState(this->liveOperation->state); + + if (this->liveReader()) { + if (this->state.error) emit this->loadFailed(this->state.error); + else emit this->loaded(); + } else { + if (this->state.error) emit this->saveFailed(this->state.error); + else emit this->saved(); + } + + this->liveOperation = nullptr; } void FileView::reload() { this->updatePath(); } -bool FileView::blockUntilLoaded() { - if (this->reader != nullptr) { - QObject::disconnect(this->reader, nullptr, this, nullptr); - this->reader->block(); - this->updateState(this->reader->state); - this->reader = nullptr; +bool FileView::waitForJob() { + if (this->liveOperation != nullptr) { + QObject::disconnect(this->liveOperation, nullptr, this, nullptr); + this->liveOperation->block(); + this->writeData = FileViewData(); + this->updateState(this->liveOperation->state); + + if (this->liveReader()) { + if (this->state.error) emit this->loadFailed(this->state.error); + else emit this->loaded(); + } else { + if (this->state.error) emit this->saveFailed(this->state.error); + else emit this->saved(); + } + + this->liveOperation = nullptr; return true; } else return false; } @@ -168,10 +392,34 @@ void FileView::loadSync() { if (this->targetPath.isEmpty()) { auto state = FileViewState(); this->updateState(state); - } else if (!this->blockUntilLoaded()) { + } else if (!this->waitForJob()) { auto state = FileViewState(this->targetPath); - FileViewReader::read(state, false); + state.printErrors = this->bPrintErrors; + FileViewReader::read(this, state, false); this->updateState(state); + + if (this->state.error) emit this->loadFailed(this->state.error); + else emit this->loaded(); + } +} + +void FileView::saveSync() { + if (this->targetPath.isEmpty()) { + qmlWarning(this) << "Cannot write file, as no path has been specified."; + this->writeData = FileViewData(); + } else { + // Both reads and writes will be outdated. + if (this->liveOperation) this->cancelAsync(); + + auto state = FileViewState(this->targetPath); + state.data = this->writeData; + state.printErrors = this->bPrintErrors; + FileViewWriter::write(this, state, this->bAtomicWrites); + this->writeData = FileViewData(); + this->updateState(state); + + if (this->state.error) emit this->saveFailed(this->state.error); + else emit this->saved(); } } @@ -188,8 +436,6 @@ void FileView::updateState(FileViewState& newState) { if (dataChanged) { this->state.data = newState.data; - this->state.text = newState.text; - this->state.textDirty = newState.textDirty; } this->state.exists = newState.exists; @@ -202,15 +448,6 @@ void FileView::updateState(FileViewState& newState) { ); if (dataChanged) this->emitDataChanged(); - - if (this->state.error) emit this->loadFailed(); -} - -void FileView::textConversion() { - if (this->state.textDirty) { - this->state.text = QString::fromUtf8(this->state.data); - this->state.textDirty = false; - } } QString FileView::path() const { return this->state.path; } @@ -218,6 +455,13 @@ QString FileView::path() const { return this->state.path; } void FileView::setPath(const QString& path) { auto p = path.startsWith("file://") ? path.sliced(7) : path; if (p == this->targetPath) return; + + if (this->liveWriter()) { + this->waitForJob(); + } else { + this->cancelAsync(); + } + this->targetPath = p; this->updatePath(); } @@ -231,21 +475,31 @@ void FileView::updatePath() { } else if (this->mPreload) { this->loadAsync(true); } else { - // loadAsync will do this already - this->cancelAsync(); this->emitDataChanged(); } } -bool FileView::shouldBlock() const { +bool FileView::shouldBlockRead() const { return this->mBlockAllReads || (this->mBlockLoading && !this->mLoadedOrAsync); } +FileViewReader* FileView::liveReader() const { + return dynamic_cast(this->liveOperation); +} + +FileViewWriter* FileView::liveWriter() const { + return dynamic_cast(this->liveOperation); +} + +const FileViewData& FileView::writeCmpData() const { + return this->writeData.isEmpty() ? this->state.data : this->writeData; +} + QByteArray FileView::data() { auto guard = this->dataChangedEmitter.block(); if (!this->mPrepared) { - if (this->shouldBlock()) this->loadSync(); + if (this->shouldBlockRead()) this->loadSync(); else this->loadAsync(false); } @@ -256,12 +510,27 @@ QString FileView::text() { auto guard = this->textChangedEmitter.block(); if (!this->mPrepared) { - if (this->shouldBlock()) this->loadSync(); + if (this->shouldBlockRead()) this->loadSync(); else this->loadAsync(true); } - this->textConversion(); - return this->state.text; + return this->state.data; +} + +void FileView::setData(const QByteArray& data) { + if (this->writeCmpData().operator const QByteArray&() == data) return; + this->writeData = data; + + if (this->bBlockWrites) this->saveSync(); + else this->saveAsync(); +} + +void FileView::setText(const QString& text) { + if (this->writeCmpData().operator const QString&() == text) return; + this->writeData = text; + + if (this->bBlockWrites) this->saveSync(); + else this->saveAsync(); } void FileView::emitDataChanged() { @@ -291,12 +560,12 @@ void FileView::setPreload(bool preload) { void FileView::setBlockLoading(bool blockLoading) { if (blockLoading != this->mBlockLoading) { - auto wasBlocking = this->shouldBlock(); + auto wasBlocking = this->shouldBlockRead(); this->mBlockLoading = blockLoading; emit this->blockLoadingChanged(); - if (!wasBlocking && this->shouldBlock()) { + if (!wasBlocking && this->shouldBlockRead()) { this->emitDataChanged(); } } @@ -304,12 +573,12 @@ void FileView::setBlockLoading(bool blockLoading) { void FileView::setBlockAllReads(bool blockAllReads) { if (blockAllReads != this->mBlockAllReads) { - auto wasBlocking = this->shouldBlock(); + auto wasBlocking = this->shouldBlockRead(); this->mBlockAllReads = blockAllReads; emit this->blockAllReadsChanged(); - if (!wasBlocking && this->shouldBlock()) { + if (!wasBlocking && this->shouldBlockRead()) { this->emitDataChanged(); } } diff --git a/src/io/fileview.hpp b/src/io/fileview.hpp index 715962f..b2b768e 100644 --- a/src/io/fileview.hpp +++ b/src/io/fileview.hpp @@ -2,13 +2,17 @@ #include +#include +#include #include #include #include +#include #include #include #include #include +#include #include #include "../core/doc.hpp" @@ -16,34 +20,74 @@ namespace qs::io { +class FileViewError: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_SINGLETON; + +public: + enum Enum : quint8 { + /// No error occured. + Success = 0, + /// An unknown error occured. Check the logs for details. + Unknown = 1, + /// The file to read does not exist. + FileNotFound = 2, + /// Permission to read/write the file was not granted, or permission + /// to create parent directories was not granted when writing the file. + PermissionDenied = 3, + /// The specified path to read/write exists and was not a file. + NotAFile = 4, + }; + Q_ENUM(Enum); + + Q_INVOKABLE static QString toString(qs::io::FileViewError::Enum value); +}; + +struct FileViewData { + FileViewData() = default; + FileViewData(QString text): text(std::move(text)) {} + FileViewData(QByteArray data): data(std::move(data)) {} + + [[nodiscard]] bool operator==(const FileViewData& other) const; + [[nodiscard]] bool isEmpty() const; + + operator const QString&() const; + operator const QByteArray&() const; + +private: + mutable QString text; + mutable QByteArray data; +}; + struct FileViewState { FileViewState() = default; explicit FileViewState(QString path): path(std::move(path)) {} QString path; - QString text; - QByteArray data; - bool textDirty = false; + FileViewData data; bool exists = false; - bool error = false; + bool printErrors = true; + FileViewError::Enum error = FileViewError::Success; }; class FileView; -class FileViewReader +class FileViewOperation : public QObject , public QRunnable { Q_OBJECT; public: - explicit FileViewReader(QString path, bool doStringConversion); + explicit FileViewOperation(FileView* owner); - void run() override; void block(); - FileViewState state; + // Attempt to cancel the operation, which may or may not be possible. + // If possible, block() returns sooner. + void tryCancel(); - static void read(FileViewState& state, bool doStringConversion); + FileViewState state; signals: void done(); @@ -51,9 +95,48 @@ class FileViewReader private slots: void finished(); -private: - bool doStringConversion; +protected: QMutex blockMutex; + QPointer owner; + QAtomicInteger shouldCancel = false; + + void finishRun(); +}; + +class FileViewReader: public FileViewOperation { +public: + explicit FileViewReader(FileView* owner, bool doStringConversion) + : FileViewOperation(owner) + , doStringConversion(doStringConversion) {} + + void run() override; + + static void read( + FileView* view, + FileViewState& state, + bool doStringConversion, + const QAtomicInteger& shouldCancel = false + ); + + bool doStringConversion; +}; + +class FileViewWriter: public FileViewOperation { +public: + explicit FileViewWriter(FileView* owner, bool doAtomicWrite) + : FileViewOperation(owner) + , doAtomicWrite(doAtomicWrite) {} + + void run() override; + + static void write( + FileView* view, + FileViewState& state, + bool doAtomicWrite, + const QAtomicInteger& shouldCancel = false + ); + + bool doAtomicWrite; }; ///! Simplified reader for small files. @@ -104,6 +187,21 @@ class FileView: public QObject { /// > [!WARNING] We cannot think of a valid use case for this. /// > You almost definitely want @@blockLoading. QSDOC_PROPERTY_OVERRIDE(bool blockAllReads READ blockAllReads WRITE setBlockAllReads NOTIFY blockAllReadsChanged); + /// If true (default false), all calls to @@setText or @@setData will block the + /// UI thread until the write succeeds or fails. + /// + /// > [!WARNING] Blocking operations should be used carefully to avoid stutters and other performance + /// > degradations. Blocking means that your interface **WILL NOT FUNCTION** during the call. + Q_PROPERTY(bool blockWrites READ default WRITE default NOTIFY blockWritesChanged BINDABLE bindableBlockWrites); + /// If true (default), all calls to @@setText or @@setData will be performed atomically, + /// meaning if the write fails for any reason, the file will not be modified. + /// + /// > [!NOTE] This works by creating another file with the desired content, and renaming + /// > it over the existing file if successful. + Q_PROPERTY(bool atomicWrites READ default WRITE default NOTIFY blockWritesChanged BINDABLE bindableAtomicWrites); + /// If true (default), read or write errors will be printed to the quickshell logs. + /// If false, all known errors will not be printed. + QSDOC_PROPERTY_OVERRIDE(bool printErrors READ default WRITE default NOTIFY printErrorsChanged); QSDOC_HIDE Q_PROPERTY(QString __path READ path WRITE setPath NOTIFY pathChanged); QSDOC_HIDE Q_PROPERTY(QString __text READ text NOTIFY internalTextChanged); @@ -117,6 +215,7 @@ class FileView: public QObject { Q_PROPERTY(bool loaded READ isLoadedOrAsync NOTIFY loadedOrAsyncChanged); QSDOC_HIDE Q_PROPERTY(bool __blockLoading READ blockLoading WRITE setBlockLoading NOTIFY blockLoadingChanged); QSDOC_HIDE Q_PROPERTY(bool __blockAllReads READ blockAllReads WRITE setBlockAllReads NOTIFY blockAllReadsChanged); + QSDOC_HIDE Q_PROPERTY(bool __printErrors READ default WRITE default NOTIFY printErrorsChanged BINDABLE bindablePrintErrors); // clang-format on QML_NAMED_ELEMENT(FileViewInternal); QSDOC_NAMED_ELEMENT(FileView); @@ -162,7 +261,7 @@ class FileView: public QObject { /// Block all operations until the currently running load completes. /// /// > [!WARNING] See @@blockLoading for an explanation and warning about blocking. - Q_INVOKABLE bool blockUntilLoaded(); + Q_INVOKABLE bool waitForJob(); /// Unload the loaded file and reload it, usually in response to changes. /// /// This will not block if @@blockLoading is set, only if @@blockAllReads is true. @@ -175,9 +274,39 @@ class FileView: public QObject { [[nodiscard]] QByteArray data(); [[nodiscard]] QString text(); + // These generally should not be called prior to component completion, making it safe not to force + // property resolution. + + /// Sets the content of the file specified by @@path as an [ArrayBuffer]. + /// + /// @@atomicWrites and @@blockWrites affect the behavior of this function. + /// + /// @@saved() or @@saveFailed() will be emitted on completion. + Q_INVOKABLE void setData(const QByteArray& data); + /// Sets the content of the file specified by @@path as text. + /// + /// @@atomicWrites and @@blockWrites affect the behavior of this function. + /// + /// @@saved() or @@saveFailed() will be emitted on completion. + /// + /// [ArrayBuffer]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer + Q_INVOKABLE void setText(const QString& text); + + // Const bindables functions silently do nothing on setValue. + [[nodiscard]] QBindable bindableBlockWrites() { return &this->bBlockWrites; } + [[nodiscard]] QBindable bindableAtomicWrites() { return &this->bAtomicWrites; } + + [[nodiscard]] QBindable bindablePrintErrors() { return &this->bPrintErrors; } + signals: - ///! Fires if the file failed to load. A warning will be printed in the log. - void loadFailed(); + /// Emitted if the file was loaded successfully. + void loaded(); + /// Emitted if the file failed to load. + void loadFailed(qs::io::FileViewError::Enum error); + /// Emitted if the file was saved successfully. + void saved(); + /// Emitted if the file failed to save. + void saveFailed(qs::io::FileViewError::Enum error); void pathChanged(); QSDOC_HIDE void internalTextChanged(); @@ -188,22 +317,30 @@ class FileView: public QObject { void loadedOrAsyncChanged(); void blockLoadingChanged(); void blockAllReadsChanged(); + void blockWritesChanged(); + void atomicWritesChanged(); + void printErrorsChanged(); private slots: - void readerFinished(); + void operationFinished(); private: void loadAsync(bool doStringConversion); + void saveAsync(); void cancelAsync(); void loadSync(); + void saveSync(); void updateState(FileViewState& newState); - void textConversion(); void updatePath(); - [[nodiscard]] bool shouldBlock() const; + [[nodiscard]] bool shouldBlockRead() const; + [[nodiscard]] FileViewReader* liveReader() const; + [[nodiscard]] FileViewWriter* liveWriter() const; + [[nodiscard]] const FileViewData& writeCmpData() const; FileViewState state; - FileViewReader* reader = nullptr; + FileViewData writeData; + FileViewOperation* liveOperation = nullptr; QString pathInFlight; QString targetPath; @@ -233,6 +370,12 @@ private slots: DECLARE_MEMBER_WITH_GET(FileView, blockLoading, mBlockLoading, blockLoadingChanged); DECLARE_MEMBER_WITH_GET(FileView, blockAllReads, mBlockAllReads, blockAllReadsChanged); + // clang-format off + Q_OBJECT_BINDABLE_PROPERTY(FileView, bool, bBlockWrites, &FileView::blockWritesChanged); + Q_OBJECT_BINDABLE_PROPERTY_WITH_ARGS(FileView, bool, bAtomicWrites, true, &FileView::atomicWritesChanged); + Q_OBJECT_BINDABLE_PROPERTY_WITH_ARGS(FileView, bool, bPrintErrors, true, &FileView::printErrorsChanged); + // clang-format on + void setPreload(bool preload); void setBlockLoading(bool blockLoading); void setBlockAllReads(bool blockAllReads);