Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
207 changes: 171 additions & 36 deletions src/extensionsIntegrated/MigrateAssist/migrator.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
TaskManager = require("features/TaskManager"),
PreferencesManager = require("preferences/PreferencesManager"),
CommandManager = require("command/CommandManager"),
FileUtils = require("file/FileUtils"),
Commands = require("command/Commands"),
Constants = require("./constants");

Expand Down Expand Up @@ -195,6 +196,16 @@
Strings.MIGRATE_PROGRESS_TITLE,
StringUtils.format(Strings.MIGRATE_PROGRESS_STATUS, 0, totalFiles),
`<i class="fa-solid fa-download"></i>`);

// Open the task list rather than leaving the copy behind a click. Nothing else announces the
// migration now that there is no dialog up front, so without this a user who happens to be
// looking elsewhere never learns why the app is busy.
// Guarded because show() toggles the dropdown: firing it while one is already open would
// close that instead. TaskManager closes this itself once the last task finishes.
if (!$(".dropdown-menu:visible").length) {
task.show();
}

return {
update: function (done) {
task.setProgressPercent(Math.round((done / totalFiles) * 100));
Expand All @@ -219,9 +230,8 @@
* it finished would undo the point of moving progress out of a dialog in the first place.
*/
/**
* Errors are the one thing that earns an interruption here. Everything else rides on the status
* bar task: if that is already telling the user what is happening, a dialog repeating it is just
* another click for them.
* Nothing interrupts while the transfer is running: the status bar task carries that. Dialogs
* are reserved for the two moments the user has to act on, finishing and failing.
*/
function _errorDialog(title, message) {
Dialogs.showModalDialog(DefaultDialogs.DIALOG_ID_ERROR, title, message);
Expand All @@ -246,23 +256,76 @@
+ StringUtils.format(Strings.CMD_MIGRATE_DATA, Constants.getLegacyDomainName());
}

function _showCompletion(migratedFiles, failed) {
const $actions = $("<div>").addClass("migrate-assist-toast-actions");
const $reload = $("<button>").addClass("btn primary btn-mini")
.text(Strings.MIGRATE_RELOAD_NOW);
$reload.on("click", function () {
CommandManager.execute(Commands.APP_RELOAD);
});
$actions.append($reload);
if (failed.length) {
$actions.prepend($("<div>").addClass("migrate-assist-toast-detail")
.text(StringUtils.format(Strings.MIGRATE_DONE_PARTIAL, failed.length)));
/**
* Buckets a migrated file under the project or extension it belongs to, so the summary can name
* what actually moved rather than only a file count.
*/
function _recordCategory(filePath, projects, extensions) {

Check warning on line 263 in src/extensionsIntegrated/MigrateAssist/migrator.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Move function '_recordCategory' to the outer scope.

See more on https://sonarcloud.io/project/issues?id=phcode-dev_phoenix&issues=AaBEtSAUmykm2BcQOXzf&open=AaBEtSAUmykm2BcQOXzf&pullRequest=3146
const parts = filePath.split("/");
// /fs/local/<project>/...
if (filePath.startsWith("/fs/local/") && parts[3]) {
projects.add(parts[3]);
// /fs/app/extensions/<user|disabled>/<extension>/...
} else if (filePath.startsWith("/fs/app/extensions/") && parts[5]) {
extensions.add(parts[5]);
}
}

/**
* Success is a dialog, not a notification. The user's data has just moved and the copied
* extensions and theme only load on the next boot, so there is something they have to do. A
* notification they can miss means reloading later, seeing nothing different, and concluding it
* failed. This is the one moment in the flow worth interrupting for.
*
* The summary says what moved and where it came from. "All done" on its own only prompts the
* question of what, exactly, is done.
*/
function _showCompletion(summary) {
const items = [];
if (summary.projects) {
items.push(StringUtils.format(Strings.MIGRATE_DONE_PROJECTS, summary.projects));
}
if (summary.extensions) {
items.push(StringUtils.format(Strings.MIGRATE_DONE_EXTENSIONS, summary.extensions));
}
if (summary.settings) {
items.push(Strings.MIGRATE_DONE_SETTINGS);
}

let message = StringUtils.format(Strings.MIGRATE_DONE_FROM, Constants.getLegacyDomainName());
message = message + "<ul>" + items.map((item) => `<li>${item}</li>`).join("") + "</ul>";
if (summary.failed) {
// Do not call a partial copy complete. Say how much is missing and what happens next.
message = message + StringUtils.format(Strings.MIGRATE_PARTIAL_COUNT,
summary.failed, summary.total);
message = message + "<br><br>" + (summary.outOfRetries
? StringUtils.format(Strings.MIGRATE_INTERRUPTED_FINAL, _menuPath())
: Strings.MIGRATE_INTERRUPTED_RETRY);
} else {
message = message + StringUtils.format(Strings.MIGRATE_DONE_TOTAL, summary.files);
}
_toast(Strings.MIGRATE_DONE_TITLE,
StringUtils.format(Strings.MIGRATE_DONE_MESSAGE, migratedFiles),
failed.length ? NotificationUI.NOTIFICATION_STYLES_CSS_CLASS.WARNING
: NotificationUI.NOTIFICATION_STYLES_CSS_CLASS.SUCCESS,
$actions);

Dialogs.showModalDialog(
summary.failed ? DefaultDialogs.DIALOG_ID_ERROR : DefaultDialogs.DIALOG_ID_INFO,
summary.failed ? Strings.MIGRATE_PARTIAL_TITLE : Strings.MIGRATE_DONE_TITLE,
message,
[
{
className: Dialogs.DIALOG_BTN_CLASS_NORMAL,
id: Dialogs.DIALOG_BTN_CANCEL,
text: Strings.MIGRATE_RELOAD_LATER
},
{
className: Dialogs.DIALOG_BTN_CLASS_PRIMARY,
id: Dialogs.DIALOG_BTN_OK,
text: Strings.MIGRATE_RELOAD_NOW
}
]
).done(function (buttonId) {
if (buttonId === Dialogs.DIALOG_BTN_OK) {
CommandManager.execute(Commands.APP_RELOAD);
}
});
}

/**
Expand Down Expand Up @@ -291,8 +354,58 @@
PreferencesManager.fileChanged(prefFile);
}

function _applyPhStore(phStore) {
// Kept in step with RecentProjects, which caps its own list at the same number.
const RECENT_PROJECTS_KEY = "STATE_recentProjects",
MAX_RECENT_PROJECTS = 20;

/**
* Recent projects cannot be copied across verbatim. The list is just absolute paths, and two
* kinds of them are dead on arrival: /mnt entries are File System Access mounts whose handles
* are origin bound and were never migrated, and anything that failed to copy is not there
* either. Both would sit in the dropdown looking openable and fail when clicked.
*
* Entries are also merged rather than replaced, so projects the user already opened on this
* origin are not thrown away by the migration.
*/
async function _applyRecentProjects(incoming) {
if (!Array.isArray(incoming)) {
return;
}
// Normalise before comparing. RecentProjects stores paths without a trailing slash while
// ProjectManager hands them out with one, and both forms pass an existence check, so
// comparing raw strings lets "default project" and "default project/" through as two
// separate entries. The user then sees the same project twice and one of them fails to open.
const merged = [];
for (const rawPath of incoming.concat(PhStore.getItem(RECENT_PROJECTS_KEY) || [])) {
if (!rawPath) {
continue;
}
const projectPath = FileUtils.stripTrailingSlash(rawPath);
if (projectPath && merged.indexOf(projectPath) === -1) {

Check warning on line 384 in src/extensionsIntegrated/MigrateAssist/migrator.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use `.includes()`, rather than `.indexOf()`, when checking for existence.

See more on https://sonarcloud.io/project/issues?id=phcode-dev_phoenix&issues=AaBEtSAUmykm2BcQOXzg&open=AaBEtSAUmykm2BcQOXzg&pullRequest=3146
merged.push(projectPath);
}
}
const mountDir = Phoenix.VFS.getMountDir();
const usable = [];
for (const projectPath of merged) {
if (projectPath.startsWith(mountDir)) {
continue;
}
if (await Phoenix.VFS.existsAsync(projectPath)) {
usable.push(projectPath);
}
}
PhStore.setItem(RECENT_PROJECTS_KEY, usable.slice(0, MAX_RECENT_PROJECTS));
Metrics.valueEvent(Metrics.EVENT_TYPE.PLATFORM, "migrateAssist", "recentsDropped",
merged.length - usable.length);
}

async function _applyPhStore(phStore) {
for (const key of Object.keys(phStore || {})) {
if (key === RECENT_PROJECTS_KEY) {
await _applyRecentProjects(phStore[key]);
continue;
}
PhStore.setItem(key, phStore[key]);
}
}
Expand All @@ -311,6 +424,9 @@
let progress = null;
let transferStarted = false;
let attemptNumber = 0;
// At run scope rather than inside the try, so a failure can say how far it got. "Did not
// finish" on its own leaves the user unable to tell a total failure from a near miss.
let totalFiles = 0, migratedFiles = 0;
try {
const scan = await bridge.scan();
Metrics.valueEvent(Metrics.EVENT_TYPE.PLATFORM, "migrateAssist", "scanMs",
Expand Down Expand Up @@ -341,8 +457,9 @@
progress = _startProgressTask(scan.files.length);

const failed = [];
let migratedFiles = 0;
totalFiles = scan.files.length;
let migratedBytes = 0;
const migratedProjects = new Set(), migratedExtensions = new Set();
for (const file of scan.files) {
try {
const bytes = await bridge.fetchFile(file.path);
Expand All @@ -352,6 +469,7 @@
window.fs.BYTE_ARRAY_ENCODING);
migratedFiles = migratedFiles + 1;
migratedBytes = migratedBytes + bytes.byteLength;
_recordCategory(file.path, migratedProjects, migratedExtensions);
} catch (err) {
// One unreadable file should not cost the user everything else.
console.error("MigrateAssist: could not copy", file.path, err);
Expand All @@ -368,13 +486,20 @@
failed.push("phcode.json");
}
}
_applyPhStore(scan.phStore);

PhStore.setItem(Constants.MIGRATION_DONE_KEY, {
at: Date.now(),
files: migratedFiles,
failed: failed.length
});
await _applyPhStore(scan.phStore);

// Only a clean run counts as done. Per file errors are caught inside the loop so the
// run can continue, which means a bridge that dies half way looks like 800 individual
// failures rather than one crash, and would otherwise be recorded as a success that is
// never retried. Anything less than a full copy stays retryable.
const outOfRetries = attemptNumber >= Constants.MAX_AUTO_ATTEMPTS;
if (!failed.length) {
PhStore.setItem(Constants.MIGRATION_DONE_KEY, {
at: Date.now(),
files: migratedFiles,
failed: 0
});
}
const elapsedMs = Math.round(performance.now() - startedAt);
Metrics.countEvent(Metrics.EVENT_TYPE.PLATFORM, "migrateAssist",
failed.length ? "completedWithErrors" : "completed");
Expand All @@ -394,7 +519,15 @@
}

progress.succeed(migratedFiles);
_showCompletion(migratedFiles, failed);
_showCompletion({
files: migratedFiles,
total: totalFiles,
projects: migratedProjects.size,
extensions: migratedExtensions.size,
settings: !!scan.prefs,
failed: failed.length,
outOfRetries: outOfRetries
});
return RESULT_MIGRATED;
} catch (err) {
console.error("MigrateAssist: migration could not run", err);
Expand All @@ -410,13 +543,14 @@
Math.round((performance.now() - startedAt) / 1000));
const outOfRetries = attemptNumber >= Constants.MAX_AUTO_ATTEMPTS;
_errorDialog(Strings.MIGRATE_INTERRUPTED_TITLE,
outOfRetries
StringUtils.format(Strings.MIGRATE_INTERRUPTED_PROGRESS,
migratedFiles, totalFiles, Constants.getLegacyDomainName())
+ "<br><br>"
+ (outOfRetries
// No more automatic attempts, so hand them the exact menu entry to use
// rather than leaving them to find it.
? StringUtils.format(Strings.MIGRATE_INTERRUPTED_FINAL,
Constants.getLegacyDomainName(), _menuPath())
: StringUtils.format(Strings.MIGRATE_INTERRUPTED_MESSAGE,
Constants.getLegacyDomainName()));
// rather than leaving them to work out what to do next.
? StringUtils.format(Strings.MIGRATE_INTERRUPTED_FINAL, _menuPath())
: Strings.MIGRATE_INTERRUPTED_RETRY));
return RESULT_INTERRUPTED;
}
Metrics.countEvent(Metrics.EVENT_TYPE.PLATFORM, "migrateAssist",
Expand Down Expand Up @@ -474,7 +608,8 @@
NotificationUI.NOTIFICATION_STYLES_CSS_CLASS.INFO);
} else if (result === RESULT_UNREACHABLE) {
_errorDialog(Strings.MIGRATE_UNREACHABLE_TITLE,
StringUtils.format(Strings.MIGRATE_UNREACHABLE_MESSAGE, Constants.getLegacyDomainName()));
StringUtils.format(Strings.MIGRATE_UNREACHABLE_MESSAGE,
Constants.getLegacyDomainName(), _menuPath()));
}
}

Expand Down
19 changes: 13 additions & 6 deletions src/nls/root/strings.js
Original file line number Diff line number Diff line change
Expand Up @@ -2936,16 +2936,23 @@ define({
"MIGRATE_STAY_HERE": "Stay here",
"MIGRATE_PROGRESS_TITLE": "Bringing your data over",
"MIGRATE_PROGRESS_STATUS": "{0} of {1} files copied",
"MIGRATE_DONE_TITLE": "All done.",
"MIGRATE_DONE_MESSAGE": "{0} files were copied over. Reload {APP_NAME} to start using them.",
"MIGRATE_DONE_PARTIAL": "{0} file(s) could not be copied. They are still available on the old site.",
"MIGRATE_DONE_TITLE": "Migration complete",
"MIGRATE_DONE_FROM": "Brought over from {0}:",
"MIGRATE_DONE_PROJECTS": "{0} project(s)",
"MIGRATE_DONE_EXTENSIONS": "{0} extension(s) and theme(s)",
"MIGRATE_DONE_SETTINGS": "Your settings, including the editor theme",
"MIGRATE_DONE_TOTAL": "{0} files in total. Reload {APP_NAME} to start using them.",
"MIGRATE_PARTIAL_TITLE": "Migration did not finish",
"MIGRATE_PARTIAL_COUNT": "{0} of {1} files could not be copied and are still on the old site.",
"MIGRATE_RELOAD_NOW": "Reload",
"MIGRATE_RELOAD_LATER": "Later",
"MIGRATE_NOTHING_TITLE": "Nothing to bring over",
"MIGRATE_NOTHING_MESSAGE": "We could not find any projects, settings or extensions on {0} that need copying.",
"MIGRATE_INTERRUPTED_TITLE": "Migration did not finish",
"MIGRATE_INTERRUPTED_MESSAGE": "The connection to {0} was lost part way through. Anything already copied has been kept. You can pick up where this left off any time from the Help menu.",
"MIGRATE_INTERRUPTED_FINAL": "The connection to {0} was lost again. Anything already copied has been kept. To finish the rest, choose {1}.",
"MIGRATE_INTERRUPTED_PROGRESS": "{0} of {1} files were copied from {2} before the connection was lost. Those files have been kept, so nothing was lost.",
"MIGRATE_INTERRUPTED_RETRY": "{APP_NAME} will try the rest the next time you open it.",
"MIGRATE_INTERRUPTED_FINAL": "To copy the rest, choose {0}.",
"MIGRATE_UNREACHABLE_TITLE": "Could not reach the old site",
"MIGRATE_UNREACHABLE_MESSAGE": "{APP_NAME} could not connect to {0} to check for your data. Please check your connection and try again.",
"MIGRATE_UNREACHABLE_MESSAGE": "{APP_NAME} could not connect to {0} to look for your projects and settings, so nothing has been changed. Check your internet connection, then try again with {1}.",
"CMD_MIGRATE_DATA": "Migrate My Data From {0}\u2026"
});
15 changes: 0 additions & 15 deletions src/styles/Extn-MigrateAssist.less
Original file line number Diff line number Diff line change
Expand Up @@ -64,18 +64,3 @@
background-position: -200% 0;
}
}

// Completion toast: the reload action and the partial-failure note sit under the message.
.migrate-assist-toast-actions {
margin-top: 10px;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}

.migrate-assist-toast-detail {
margin-right: auto;
font-size: 0.9em;
opacity: 0.8;
}
Loading