Add Finder-style New Folder with inline rename on the Switch.
Create unique folders without a crashing prompt, then focus the name for editing; also fix folder association type and UTF-8 MTP string encoding.
This commit is contained in:
@@ -22,17 +22,46 @@ void MTPOperations::put_le64(std::vector<uint8_t>& b, uint64_t v) {
|
|||||||
// UTF-8 → MTP UTF-16LE string: [1-byte len][len × 2-byte UTF-16LE chars]
|
// UTF-8 → MTP UTF-16LE string: [1-byte len][len × 2-byte UTF-16LE chars]
|
||||||
void MTPOperations::encode_mtp_string(std::vector<uint8_t>& buf, const std::string& utf8) {
|
void MTPOperations::encode_mtp_string(std::vector<uint8_t>& buf, const std::string& utf8) {
|
||||||
if (utf8.empty()) { buf.push_back(0); return; }
|
if (utf8.empty()) { buf.push_back(0); return; }
|
||||||
// Simple ASCII-only encoding (sufficient for filenames in practice)
|
|
||||||
size_t len = std::min(utf8.size(), size_t(255)) + 1; // +1 for null terminator
|
std::vector<uint16_t> units;
|
||||||
buf.push_back(static_cast<uint8_t>(len));
|
units.reserve(utf8.size() + 1);
|
||||||
for (size_t i = 0; i < len - 1; ++i) {
|
size_t i = 0;
|
||||||
uint8_t c = static_cast<uint8_t>(utf8[i]);
|
while (i < utf8.size() && units.size() < 254) {
|
||||||
buf.push_back(c);
|
const unsigned char c = static_cast<unsigned char>(utf8[i]);
|
||||||
buf.push_back(0);
|
uint32_t cp = 0;
|
||||||
|
if (c < 0x80) {
|
||||||
|
cp = c; i += 1;
|
||||||
|
} else if ((c & 0xE0) == 0xC0 && i + 1 < utf8.size()) {
|
||||||
|
cp = (uint32_t(c & 0x1F) << 6) | (utf8[i + 1] & 0x3F);
|
||||||
|
i += 2;
|
||||||
|
} else if ((c & 0xF0) == 0xE0 && i + 2 < utf8.size()) {
|
||||||
|
cp = (uint32_t(c & 0x0F) << 12)
|
||||||
|
| (uint32_t(utf8[i + 1] & 0x3F) << 6)
|
||||||
|
| (utf8[i + 2] & 0x3F);
|
||||||
|
i += 3;
|
||||||
|
} else if ((c & 0xF8) == 0xF0 && i + 3 < utf8.size()) {
|
||||||
|
cp = (uint32_t(c & 0x07) << 18)
|
||||||
|
| (uint32_t(utf8[i + 1] & 0x3F) << 12)
|
||||||
|
| (uint32_t(utf8[i + 2] & 0x3F) << 6)
|
||||||
|
| (utf8[i + 3] & 0x3F);
|
||||||
|
i += 4;
|
||||||
|
} else {
|
||||||
|
cp = '?'; i += 1;
|
||||||
|
}
|
||||||
|
if (cp >= 0x10000) {
|
||||||
|
cp -= 0x10000;
|
||||||
|
units.push_back(static_cast<uint16_t>(0xD800 + (cp >> 10)));
|
||||||
|
if (units.size() < 254)
|
||||||
|
units.push_back(static_cast<uint16_t>(0xDC00 + (cp & 0x3FF)));
|
||||||
|
} else {
|
||||||
|
units.push_back(static_cast<uint16_t>(cp));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Null terminator
|
|
||||||
buf.push_back(0);
|
const size_t len = units.size() + 1; // includes null terminator
|
||||||
buf.push_back(0);
|
buf.push_back(static_cast<uint8_t>(len));
|
||||||
|
for (uint16_t u : units) put_le16(buf, u);
|
||||||
|
put_le16(buf, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Parsing helpers ──────────────────────────────────────────────────────────
|
// ─── Parsing helpers ──────────────────────────────────────────────────────────
|
||||||
@@ -203,7 +232,8 @@ std::vector<uint8_t> MTPOperations::build_object_info(uint32_t storage_id,
|
|||||||
put_le32(b, 0); // image_pix_h
|
put_le32(b, 0); // image_pix_h
|
||||||
put_le32(b, 0); // image_bit_depth
|
put_le32(b, 0); // image_bit_depth
|
||||||
put_le32(b, parent_handle);
|
put_le32(b, parent_handle);
|
||||||
put_le16(b, 0); // association_type
|
// Generic Folder association (0x0001) required for directory objects.
|
||||||
|
put_le16(b, format == static_cast<uint16_t>(ObjectFormat::Association) ? 0x0001 : 0);
|
||||||
put_le32(b, 0); // association_desc
|
put_le32(b, 0); // association_desc
|
||||||
put_le32(b, 0); // sequence_number
|
put_le32(b, 0); // sequence_number
|
||||||
encode_mtp_string(b, filename);
|
encode_mtp_string(b, filename);
|
||||||
@@ -330,4 +360,18 @@ uint32_t MTPOperations::create_directory(uint32_t storage_id,
|
|||||||
return resp.params.size() >= 3 ? resp.params[2] : 0;
|
return resp.params.size() >= 3 ? resp.params[2] : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void MTPOperations::rename_object(uint32_t handle, const std::string& new_name) {
|
||||||
|
std::lock_guard lock(session_.mutex());
|
||||||
|
std::array<uint32_t,2> params{ handle, PROP_OBJECT_FILENAME };
|
||||||
|
session_.send_command(OpCode::SetObjectPropValue, params);
|
||||||
|
|
||||||
|
std::vector<uint8_t> payload;
|
||||||
|
encode_mtp_string(payload, new_name);
|
||||||
|
session_.send_data(payload);
|
||||||
|
|
||||||
|
auto resp = session_.receive_response();
|
||||||
|
if (resp.code != ResponseCode::OK)
|
||||||
|
throw MTPException(resp.code, MTPException::code_name(resp.code));
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace mtp
|
} // namespace mtp
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ public:
|
|||||||
uint32_t parent_handle,
|
uint32_t parent_handle,
|
||||||
const std::string& name);
|
const std::string& name);
|
||||||
|
|
||||||
|
// Rename an existing object via SetObjectPropValue(ObjectFileName).
|
||||||
|
void rename_object(uint32_t handle, const std::string& new_name);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
MTPSession& session_;
|
MTPSession& session_;
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,12 @@ public:
|
|||||||
void do_cancel(const std::string& id);
|
void do_cancel(const std::string& id);
|
||||||
void do_cancel_all();
|
void do_cancel_all();
|
||||||
void do_delete(uint32_t handle);
|
void do_delete(uint32_t handle);
|
||||||
void do_create_folder(const std::string& name);
|
// Create a uniquely named folder (default "New Folder") in the current remote dir.
|
||||||
|
// On success fills out_* and returns true.
|
||||||
|
bool do_create_folder(const std::string& preferred_name,
|
||||||
|
uint32_t& out_handle, uint32_t& out_storage_id,
|
||||||
|
std::string& out_name);
|
||||||
|
bool do_rename(uint32_t handle, const std::string& new_name);
|
||||||
void reconnect();
|
void reconnect();
|
||||||
|
|
||||||
// macOS sleep/wake — tear down stale libusb state and reconnect.
|
// macOS sleep/wake — tear down stale libusb state and reconnect.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
#include <thread>
|
#include <thread>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
#include <unordered_set>
|
||||||
|
|
||||||
namespace fs = std::filesystem;
|
namespace fs = std::filesystem;
|
||||||
namespace omniMTP {
|
namespace omniMTP {
|
||||||
@@ -515,12 +516,94 @@ void App::do_delete(uint32_t h) {
|
|||||||
catch (...) {}
|
catch (...) {}
|
||||||
refresh_remote();
|
refresh_remote();
|
||||||
}
|
}
|
||||||
void App::do_create_folder(const std::string& name) {
|
bool App::do_create_folder(const std::string& preferred_name,
|
||||||
if (remote_nav_stack_.empty()) return;
|
uint32_t& out_handle, uint32_t& out_storage_id,
|
||||||
|
std::string& out_name) {
|
||||||
|
out_handle = 0;
|
||||||
|
out_storage_id = 0;
|
||||||
|
out_name.clear();
|
||||||
|
if (!connected_.load() || remote_nav_stack_.empty()) return false;
|
||||||
|
|
||||||
|
auto sanitize = [](std::string s) {
|
||||||
|
for (char& c : s) {
|
||||||
|
if (c == '/' || c == '\\' || c == ':' || c == 0) c = '_';
|
||||||
|
}
|
||||||
|
while (!s.empty() && (s.front() == ' ' || s.front() == '.'))
|
||||||
|
s.erase(s.begin());
|
||||||
|
while (!s.empty() && s.back() == ' ')
|
||||||
|
s.pop_back();
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::string base = sanitize(preferred_name);
|
||||||
|
if (base.empty()) base = "New Folder";
|
||||||
|
|
||||||
|
// Snapshot names currently visible so we can pick "New Folder 2", …
|
||||||
|
std::unordered_set<std::string> used;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(device_mtx_);
|
||||||
|
for (const auto& e : remote_entries_)
|
||||||
|
used.insert(e.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string candidate = base;
|
||||||
|
if (used.count(candidate)) {
|
||||||
|
for (int i = 2; i < 10000; ++i) {
|
||||||
|
candidate = base + " " + std::to_string(i);
|
||||||
|
if (!used.count(candidate)) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const auto& top = remote_nav_stack_.back();
|
const auto& top = remote_nav_stack_.back();
|
||||||
try { std::lock_guard lock(device_mtx_); if (ops_) ops_->create_directory(top.storage_id, top.handle, name); }
|
uint32_t handle = 0;
|
||||||
catch (...) {}
|
try {
|
||||||
|
std::lock_guard lock(device_mtx_);
|
||||||
|
if (!ops_) return false;
|
||||||
|
handle = ops_->create_directory(top.storage_id, top.handle, candidate);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
set_status("status.listing_error", {e.what()});
|
||||||
|
return false;
|
||||||
|
} catch (...) {
|
||||||
|
set_status("status.listing_error", {"Failed to create folder"});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (handle == 0) {
|
||||||
|
set_status("status.listing_error", {"Failed to create folder"});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
out_handle = handle;
|
||||||
|
out_storage_id = top.storage_id;
|
||||||
|
out_name = candidate;
|
||||||
refresh_remote();
|
refresh_remote();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool App::do_rename(uint32_t handle, const std::string& new_name) {
|
||||||
|
if (!connected_.load()) return false;
|
||||||
|
std::string clean = new_name;
|
||||||
|
for (char& c : clean) {
|
||||||
|
if (c == '/' || c == '\\' || c == ':' || c == 0) c = '_';
|
||||||
|
}
|
||||||
|
while (!clean.empty() && (clean.front() == ' ' || clean.front() == '.'))
|
||||||
|
clean.erase(clean.begin());
|
||||||
|
while (!clean.empty() && clean.back() == ' ')
|
||||||
|
clean.pop_back();
|
||||||
|
if (clean.empty()) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
std::lock_guard lock(device_mtx_);
|
||||||
|
if (!ops_) return false;
|
||||||
|
ops_->rename_object(handle, clean);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
set_status("status.listing_error", {e.what()});
|
||||||
|
return false;
|
||||||
|
} catch (...) {
|
||||||
|
set_status("status.listing_error", {"Rename failed"});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
refresh_remote();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
void App::reconnect() {
|
void App::reconnect() {
|
||||||
usb_recovery_pending_.store(true);
|
usb_recovery_pending_.store(true);
|
||||||
|
|||||||
@@ -399,8 +399,34 @@ static NSString* OmniPreferredDownloadURL(NSDictionary* release) {
|
|||||||
if (handle) _app->do_delete(handle.unsignedIntValue);
|
if (handle) _app->do_delete(handle.unsignedIntValue);
|
||||||
|
|
||||||
} else if ([action isEqualToString:@"create_folder"]) {
|
} else if ([action isEqualToString:@"create_folder"]) {
|
||||||
|
NSString* preferred = body[@"defaultName"];
|
||||||
|
std::string preferredName = (preferred && preferred.length > 0)
|
||||||
|
? preferred.UTF8String : "New Folder";
|
||||||
|
uint32_t handle = 0, storageId = 0;
|
||||||
|
std::string createdName;
|
||||||
|
bool ok = _app->do_create_folder(preferredName, handle, storageId, createdName);
|
||||||
|
if (!ok) {
|
||||||
|
[self sendNullResult:rid];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NSDictionary* payload = @{
|
||||||
|
@"handle": @(handle),
|
||||||
|
@"storageId": @(storageId),
|
||||||
|
@"name": [NSString stringWithUTF8String:createdName.c_str()],
|
||||||
|
};
|
||||||
|
NSData* data = [NSJSONSerialization dataWithJSONObject:payload options:0 error:nil];
|
||||||
|
NSString* json = data ? [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] : @"null";
|
||||||
|
[self sendRawResult:json responseId:rid];
|
||||||
|
return;
|
||||||
|
|
||||||
|
} else if ([action isEqualToString:@"rename_remote"]) {
|
||||||
|
NSNumber* handle = body[@"handle"];
|
||||||
NSString* name = body[@"name"];
|
NSString* name = body[@"name"];
|
||||||
if (name && name.length > 0) _app->do_create_folder(name.UTF8String);
|
bool ok = NO;
|
||||||
|
if (handle && name)
|
||||||
|
ok = _app->do_rename(handle.unsignedIntValue, name.UTF8String);
|
||||||
|
[self sendRawResult:(ok ? @"true" : @"false") responseId:rid];
|
||||||
|
return;
|
||||||
|
|
||||||
} else if ([action isEqualToString:@"reconnect"]) {
|
} else if ([action isEqualToString:@"reconnect"]) {
|
||||||
_app->reconnect();
|
_app->reconnect();
|
||||||
|
|||||||
@@ -361,6 +361,28 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.fname-text {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.fname-edit {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 80px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #1e1e22;
|
||||||
|
color: var(--label);
|
||||||
|
font: inherit;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px rgba(10,132,255,0.25);
|
||||||
|
user-select: text !important;
|
||||||
|
-webkit-user-select: text !important;
|
||||||
}
|
}
|
||||||
.dot {
|
.dot {
|
||||||
width: 7px;
|
width: 7px;
|
||||||
|
|||||||
@@ -127,6 +127,70 @@ async function confirmDialog(message) {
|
|||||||
return result === true || result === 'true';
|
return result === true || result === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _pendingRenameHandle = null;
|
||||||
|
let _inlineRenameActive = false;
|
||||||
|
|
||||||
|
async function createFolderInline() {
|
||||||
|
if (!S.connected || _inlineRenameActive) return;
|
||||||
|
const result = await call('create_folder', {
|
||||||
|
defaultName: t('prompt.new_folder_default'),
|
||||||
|
});
|
||||||
|
if (!result || result.handle == null) return;
|
||||||
|
_pendingRenameHandle = String(result.handle);
|
||||||
|
_remoteFingerprint = '';
|
||||||
|
renderRemoteFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startInlineRename(handleKey) {
|
||||||
|
const key = String(handleKey);
|
||||||
|
const row = document.querySelector(`#remote-files .frow[data-key="${CSS.escape(key)}"]`);
|
||||||
|
if (!row) {
|
||||||
|
_pendingRenameHandle = key;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (row.querySelector('.fname-edit')) return;
|
||||||
|
_pendingRenameHandle = null;
|
||||||
|
|
||||||
|
const textEl = row.querySelector('.fname-text');
|
||||||
|
if (!textEl) return;
|
||||||
|
const oldName = textEl.textContent;
|
||||||
|
const className = textEl.className;
|
||||||
|
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.className = 'fname-edit';
|
||||||
|
input.value = oldName;
|
||||||
|
input.setAttribute('spellcheck', 'false');
|
||||||
|
textEl.replaceWith(input);
|
||||||
|
_inlineRenameActive = true;
|
||||||
|
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
|
||||||
|
let finished = false;
|
||||||
|
const finish = async (commit) => {
|
||||||
|
if (finished) return;
|
||||||
|
finished = true;
|
||||||
|
_inlineRenameActive = false;
|
||||||
|
const newName = input.value.trim();
|
||||||
|
if (commit && newName && newName !== oldName) {
|
||||||
|
await call('rename_remote', { handle: Number(key), name: newName });
|
||||||
|
}
|
||||||
|
_remoteFingerprint = '';
|
||||||
|
renderRemoteFiles();
|
||||||
|
};
|
||||||
|
|
||||||
|
input.addEventListener('keydown', e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); finish(true); }
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); finish(false); }
|
||||||
|
});
|
||||||
|
input.addEventListener('blur', () => finish(true));
|
||||||
|
input.addEventListener('mousedown', e => e.stopPropagation());
|
||||||
|
input.addEventListener('click', e => e.stopPropagation());
|
||||||
|
input.addEventListener('dblclick', e => e.stopPropagation());
|
||||||
|
}
|
||||||
|
|
||||||
// ── State ─────────────────────────────────────────────────────────────────────
|
// ── State ─────────────────────────────────────────────────────────────────────
|
||||||
const S = {
|
const S = {
|
||||||
connected: false, deviceName: '',
|
connected: false, deviceName: '',
|
||||||
@@ -251,7 +315,7 @@ function buildFileRows(files, selSet, idKey) {
|
|||||||
return `<tr class="frow${sel}" data-idx="${i}" data-key="${esc(key)}" data-is-dir="${f.isDir ? '1' : '0'}">
|
return `<tr class="frow${sel}" data-idx="${i}" data-key="${esc(key)}" data-is-dir="${f.isDir ? '1' : '0'}">
|
||||||
<td class="col-name"><div class="fname-cell">
|
<td class="col-name"><div class="fname-cell">
|
||||||
<span class="dot ${cc}"></span>
|
<span class="dot ${cc}"></span>
|
||||||
<span class="${cc}">${esc(f.name)}</span>
|
<span class="fname-text ${cc}">${esc(f.name)}</span>
|
||||||
</div></td>
|
</div></td>
|
||||||
<td class="col-size">${f.isDir ? '' : esc(f.sizeStr)}</td>
|
<td class="col-size">${f.isDir ? '' : esc(f.sizeStr)}</td>
|
||||||
<td class="col-date">${esc(f.date)}</td>
|
<td class="col-date">${esc(f.date)}</td>
|
||||||
@@ -327,6 +391,9 @@ function renderRemoteFiles() {
|
|||||||
const el = document.getElementById('remote-files');
|
const el = document.getElementById('remote-files');
|
||||||
const empty = document.getElementById('remote-empty-state');
|
const empty = document.getElementById('remote-empty-state');
|
||||||
|
|
||||||
|
// Don't clobber an in-progress inline rename.
|
||||||
|
if (_inlineRenameActive) return;
|
||||||
|
|
||||||
if (!S.connected) {
|
if (!S.connected) {
|
||||||
if (empty) empty.style.display = '';
|
if (empty) empty.style.display = '';
|
||||||
el.querySelectorAll('table,.loading,.empty-state:not(#remote-empty-state)')
|
el.querySelectorAll('table,.loading,.empty-state:not(#remote-empty-state)')
|
||||||
@@ -360,6 +427,11 @@ function renderRemoteFiles() {
|
|||||||
} else {
|
} else {
|
||||||
applySelectionToDOM(el, S.selRemote);
|
applySelectionToDOM(el, S.selRemote);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_pendingRenameHandle) {
|
||||||
|
const handle = _pendingRenameHandle;
|
||||||
|
requestAnimationFrame(() => startInlineRename(handle));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachRemoteEvents(el) {
|
function attachRemoteEvents(el) {
|
||||||
@@ -443,6 +515,7 @@ function renderRemoteToolbar() {
|
|||||||
crumb.querySelectorAll('.crumb-link').forEach(el =>
|
crumb.querySelectorAll('.crumb-link').forEach(el =>
|
||||||
el.addEventListener('click', () => call('navigate_remote_to', {index: +el.dataset.idx})));
|
el.addEventListener('click', () => call('navigate_remote_to', {index: +el.dataset.idx})));
|
||||||
document.getElementById('btn-remote-up').disabled = stack.length <= 1;
|
document.getElementById('btn-remote-up').disabled = stack.length <= 1;
|
||||||
|
document.getElementById('btn-new-folder').disabled = !S.connected;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDeviceBar() {
|
function renderDeviceBar() {
|
||||||
@@ -539,25 +612,53 @@ function renderStatusBar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let _ctxFile = null;
|
let _ctxFile = null;
|
||||||
|
let _ctxMode = 'file'; // 'file' | 'blank'
|
||||||
|
|
||||||
function showRemoteCtxMenu(file, e) {
|
function positionCtxMenu(e) {
|
||||||
_ctxFile = file;
|
|
||||||
if (!S.selRemote.has(String(file.handle))) {
|
|
||||||
S.selRemote.clear();
|
|
||||||
S.selRemote.add(String(file.handle));
|
|
||||||
applySelectionToDOM(document.getElementById('remote-files'), S.selRemote);
|
|
||||||
}
|
|
||||||
const m = document.getElementById('ctx-menu');
|
const m = document.getElementById('ctx-menu');
|
||||||
m.style.left = e.clientX + 'px';
|
m.style.left = e.clientX + 'px';
|
||||||
m.style.top = e.clientY + 'px';
|
m.style.top = e.clientY + 'px';
|
||||||
m.classList.remove('hidden');
|
m.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showRemoteCtxMenu(file, e) {
|
||||||
|
_ctxFile = file;
|
||||||
|
_ctxMode = 'file';
|
||||||
|
if (!S.selRemote.has(String(file.handle))) {
|
||||||
|
S.selRemote.clear();
|
||||||
|
S.selRemote.add(String(file.handle));
|
||||||
|
applySelectionToDOM(document.getElementById('remote-files'), S.selRemote);
|
||||||
|
}
|
||||||
|
document.getElementById('ctx-new-folder').style.display = '';
|
||||||
|
document.getElementById('ctx-sep-delete').style.display = '';
|
||||||
|
document.getElementById('ctx-delete').style.display = '';
|
||||||
|
positionCtxMenu(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showBlankCtxMenu(e) {
|
||||||
|
if (!S.connected) return;
|
||||||
|
_ctxFile = null;
|
||||||
|
_ctxMode = 'blank';
|
||||||
|
document.getElementById('ctx-new-folder').style.display = '';
|
||||||
|
document.getElementById('ctx-sep-delete').style.display = 'none';
|
||||||
|
document.getElementById('ctx-delete').style.display = 'none';
|
||||||
|
positionCtxMenu(e);
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('click', e => {
|
document.addEventListener('click', e => {
|
||||||
if (e.target.closest('#ctx-menu')) return;
|
if (e.target.closest('#ctx-menu')) return;
|
||||||
document.getElementById('ctx-menu').classList.add('hidden');
|
document.getElementById('ctx-menu').classList.add('hidden');
|
||||||
});
|
});
|
||||||
document.addEventListener('contextmenu', e => { if (!e.target.closest('.frow')) { e.preventDefault(); document.getElementById('ctx-menu').classList.add('hidden'); } });
|
document.addEventListener('contextmenu', e => {
|
||||||
|
if (e.target.closest('.frow')) return;
|
||||||
|
if (e.target.closest('#remote-files') && S.connected) {
|
||||||
|
e.preventDefault();
|
||||||
|
showBlankCtxMenu(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
document.getElementById('ctx-menu').classList.add('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
window.omniClearSelection = function() {
|
window.omniClearSelection = function() {
|
||||||
const sel = window.getSelection();
|
const sel = window.getSelection();
|
||||||
@@ -588,6 +689,7 @@ document.addEventListener('selectstart', e => {
|
|||||||
|
|
||||||
document.addEventListener('mousedown', e => {
|
document.addEventListener('mousedown', e => {
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
|
if (e.target.closest('.fname-edit')) return;
|
||||||
const row = e.target.closest('#remote-files .frow');
|
const row = e.target.closest('#remote-files .frow');
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -605,6 +707,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
|
|
||||||
document.getElementById('btn-remote-up') .addEventListener('click', () => call('navigate_remote_up'));
|
document.getElementById('btn-remote-up') .addEventListener('click', () => call('navigate_remote_up'));
|
||||||
document.getElementById('btn-remote-refresh').addEventListener('click', () => call('refresh_remote'));
|
document.getElementById('btn-remote-refresh').addEventListener('click', () => call('refresh_remote'));
|
||||||
|
document.getElementById('btn-new-folder') .addEventListener('click', () => createFolderInline());
|
||||||
document.getElementById('btn-scan') .addEventListener('click', () => call('reconnect'));
|
document.getElementById('btn-scan') .addEventListener('click', () => call('reconnect'));
|
||||||
document.getElementById('btn-open-picker') .addEventListener('click', () => call('open_file_picker'));
|
document.getElementById('btn-open-picker') .addEventListener('click', () => call('open_file_picker'));
|
||||||
document.getElementById('btn-cancel-all') .addEventListener('click', () => call('cancel_all'));
|
document.getElementById('btn-cancel-all') .addEventListener('click', () => call('cancel_all'));
|
||||||
@@ -613,12 +716,15 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
const bindCtx = (id, fn) => {
|
const bindCtx = (id, fn) => {
|
||||||
document.getElementById(id).addEventListener('click', e => {
|
document.getElementById(id).addEventListener('click', e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
document.getElementById('ctx-menu').classList.add('hidden');
|
||||||
fn();
|
fn();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
bindCtx('ctx-new-folder', () => createFolderInline());
|
||||||
|
|
||||||
bindCtx('ctx-delete', async () => {
|
bindCtx('ctx-delete', async () => {
|
||||||
if (!_ctxFile) return;
|
if (!_ctxFile || _ctxMode !== 'file') return;
|
||||||
const multi = S.selRemote.size > 1 && S.selRemote.has(String(_ctxFile.handle));
|
const multi = S.selRemote.size > 1 && S.selRemote.has(String(_ctxFile.handle));
|
||||||
const message = multi
|
const message = multi
|
||||||
? t('confirm.delete_items', { n: S.selRemote.size })
|
? t('confirm.delete_items', { n: S.selRemote.size })
|
||||||
|
|||||||
@@ -33,6 +33,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<select id="storage-sel" class="storage-select"></select>
|
<select id="storage-sel" class="storage-select"></select>
|
||||||
<div id="remote-breadcrumb" class="breadcrumb"></div>
|
<div id="remote-breadcrumb" class="breadcrumb"></div>
|
||||||
|
<button class="nav-btn icon-only" id="btn-new-folder" data-i18n-title="btn.new_folder" title="New Folder" disabled>
|
||||||
|
<svg viewBox="0 0 16 16"><path d="M2 3.5A1.5 1.5 0 0 1 3.5 2H7l1.5 1.5H12.5A1.5 1.5 0 0 1 14 5v7.5a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 2 12.5v-9z" fill="none" stroke="currentColor" stroke-width="1.25"/><path d="M8 6.5v5M5.5 9h5" stroke="currentColor" stroke-width="1.25" stroke-linecap="round"/></svg>
|
||||||
|
</button>
|
||||||
<button class="nav-btn icon-only" id="btn-remote-refresh" data-i18n-title="btn.refresh" title="Refresh">
|
<button class="nav-btn icon-only" id="btn-remote-refresh" data-i18n-title="btn.refresh" title="Refresh">
|
||||||
<svg viewBox="0 0 16 16"><path d="M13.5 8A5.5 5.5 0 1 1 8 2.5V1l3 2.5L8 6V4.5A3.5 3.5 0 1 0 11.5 8h2z" fill="currentColor"/></svg>
|
<svg viewBox="0 0 16 16"><path d="M13.5 8A5.5 5.5 0 1 1 8 2.5V1l3 2.5L8 6V4.5A3.5 3.5 0 1 0 11.5 8h2z" fill="currentColor"/></svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -69,6 +72,8 @@
|
|||||||
|
|
||||||
<!-- Context menu -->
|
<!-- Context menu -->
|
||||||
<div id="ctx-menu" class="ctx-menu hidden">
|
<div id="ctx-menu" class="ctx-menu hidden">
|
||||||
|
<div class="ctx-item" id="ctx-new-folder" data-i18n="ctx.new_folder">New Folder</div>
|
||||||
|
<div class="ctx-item ctx-sep" id="ctx-sep-delete"></div>
|
||||||
<div class="ctx-item danger" id="ctx-delete" data-i18n="ctx.delete">Delete…</div>
|
<div class="ctx-item danger" id="ctx-delete" data-i18n="ctx.delete">Delete…</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -58,5 +58,9 @@
|
|||||||
"update.up_to_date_body": "OmniMTP {version} ist die aktuelle Version.",
|
"update.up_to_date_body": "OmniMTP {version} ist die aktuelle Version.",
|
||||||
"update.check_failed_title": "Update-Prüfung fehlgeschlagen",
|
"update.check_failed_title": "Update-Prüfung fehlgeschlagen",
|
||||||
"update.check_failed_body": "Der Update-Server konnte nicht erreicht werden.",
|
"update.check_failed_body": "Der Update-Server konnte nicht erreicht werden.",
|
||||||
"update.http_error": "Server antwortete mit HTTP {code}."
|
"update.http_error": "Server antwortete mit HTTP {code}.",
|
||||||
|
"btn.new_folder": "Neuer Ordner",
|
||||||
|
"ctx.new_folder": "Neuer Ordner",
|
||||||
|
"prompt.new_folder": "Ordnername:",
|
||||||
|
"prompt.new_folder_default": "Neuer Ordner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,5 +58,9 @@
|
|||||||
"update.up_to_date_body": "OmniMTP {version} is the latest release.",
|
"update.up_to_date_body": "OmniMTP {version} is the latest release.",
|
||||||
"update.check_failed_title": "Update Check Failed",
|
"update.check_failed_title": "Update Check Failed",
|
||||||
"update.check_failed_body": "Could not reach the update server.",
|
"update.check_failed_body": "Could not reach the update server.",
|
||||||
"update.http_error": "Server returned HTTP {code}."
|
"update.http_error": "Server returned HTTP {code}.",
|
||||||
|
"btn.new_folder": "New Folder",
|
||||||
|
"ctx.new_folder": "New Folder",
|
||||||
|
"prompt.new_folder": "Folder name:",
|
||||||
|
"prompt.new_folder_default": "New Folder"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,5 +58,9 @@
|
|||||||
"update.up_to_date_body": "OmniMTP {version} es la última versión.",
|
"update.up_to_date_body": "OmniMTP {version} es la última versión.",
|
||||||
"update.check_failed_title": "Error al buscar actualizaciones",
|
"update.check_failed_title": "Error al buscar actualizaciones",
|
||||||
"update.check_failed_body": "No se pudo contactar con el servidor de actualizaciones.",
|
"update.check_failed_body": "No se pudo contactar con el servidor de actualizaciones.",
|
||||||
"update.http_error": "El servidor respondió HTTP {code}."
|
"update.http_error": "El servidor respondió HTTP {code}.",
|
||||||
|
"btn.new_folder": "Nueva carpeta",
|
||||||
|
"ctx.new_folder": "Nueva carpeta",
|
||||||
|
"prompt.new_folder": "Nombre de la carpeta:",
|
||||||
|
"prompt.new_folder_default": "Nueva carpeta"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,5 +58,9 @@
|
|||||||
"update.up_to_date_body": "OmniMTP {version} est la dernière version.",
|
"update.up_to_date_body": "OmniMTP {version} est la dernière version.",
|
||||||
"update.check_failed_title": "Échec de la vérification",
|
"update.check_failed_title": "Échec de la vérification",
|
||||||
"update.check_failed_body": "Impossible de joindre le serveur de mises à jour.",
|
"update.check_failed_body": "Impossible de joindre le serveur de mises à jour.",
|
||||||
"update.http_error": "Le serveur a renvoyé HTTP {code}."
|
"update.http_error": "Le serveur a renvoyé HTTP {code}.",
|
||||||
|
"btn.new_folder": "Nouveau dossier",
|
||||||
|
"ctx.new_folder": "Nouveau dossier",
|
||||||
|
"prompt.new_folder": "Nom du dossier :",
|
||||||
|
"prompt.new_folder_default": "Nouveau dossier"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,5 +58,9 @@
|
|||||||
"update.up_to_date_body": "OmniMTP {version} è l’ultima versione.",
|
"update.up_to_date_body": "OmniMTP {version} è l’ultima versione.",
|
||||||
"update.check_failed_title": "Verifica aggiornamenti non riuscita",
|
"update.check_failed_title": "Verifica aggiornamenti non riuscita",
|
||||||
"update.check_failed_body": "Impossibile raggiungere il server degli aggiornamenti.",
|
"update.check_failed_body": "Impossibile raggiungere il server degli aggiornamenti.",
|
||||||
"update.http_error": "Il server ha risposto HTTP {code}."
|
"update.http_error": "Il server ha risposto HTTP {code}.",
|
||||||
|
"btn.new_folder": "Nuova cartella",
|
||||||
|
"ctx.new_folder": "Nuova cartella",
|
||||||
|
"prompt.new_folder": "Nome cartella:",
|
||||||
|
"prompt.new_folder_default": "Nuova cartella"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,5 +58,9 @@
|
|||||||
"update.up_to_date_body": "OmniMTP {version} が最新リリースです。",
|
"update.up_to_date_body": "OmniMTP {version} が最新リリースです。",
|
||||||
"update.check_failed_title": "アップデート確認に失敗",
|
"update.check_failed_title": "アップデート確認に失敗",
|
||||||
"update.check_failed_body": "アップデートサーバーに接続できませんでした。",
|
"update.check_failed_body": "アップデートサーバーに接続できませんでした。",
|
||||||
"update.http_error": "サーバーが HTTP {code} を返しました。"
|
"update.http_error": "サーバーが HTTP {code} を返しました。",
|
||||||
|
"btn.new_folder": "新しいフォルダ",
|
||||||
|
"ctx.new_folder": "新しいフォルダ",
|
||||||
|
"prompt.new_folder": "フォルダ名:",
|
||||||
|
"prompt.new_folder_default": "新しいフォルダ"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,5 +58,9 @@
|
|||||||
"update.up_to_date_body": "OmniMTP {version} is de nieuwste release.",
|
"update.up_to_date_body": "OmniMTP {version} is de nieuwste release.",
|
||||||
"update.check_failed_title": "Updatecontrole mislukt",
|
"update.check_failed_title": "Updatecontrole mislukt",
|
||||||
"update.check_failed_body": "Kon de updateserver niet bereiken.",
|
"update.check_failed_body": "Kon de updateserver niet bereiken.",
|
||||||
"update.http_error": "Server gaf HTTP {code} terug."
|
"update.http_error": "Server gaf HTTP {code} terug.",
|
||||||
|
"btn.new_folder": "Nieuwe map",
|
||||||
|
"ctx.new_folder": "Nieuwe map",
|
||||||
|
"prompt.new_folder": "Mapnaam:",
|
||||||
|
"prompt.new_folder_default": "Nieuwe map"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,5 +58,9 @@
|
|||||||
"update.up_to_date_body": "OmniMTP {version} é a versão mais recente.",
|
"update.up_to_date_body": "OmniMTP {version} é a versão mais recente.",
|
||||||
"update.check_failed_title": "Falha ao verificar atualizações",
|
"update.check_failed_title": "Falha ao verificar atualizações",
|
||||||
"update.check_failed_body": "Não foi possível contactar o servidor de atualizações.",
|
"update.check_failed_body": "Não foi possível contactar o servidor de atualizações.",
|
||||||
"update.http_error": "O servidor retornou HTTP {code}."
|
"update.http_error": "O servidor retornou HTTP {code}.",
|
||||||
|
"btn.new_folder": "Nova pasta",
|
||||||
|
"ctx.new_folder": "Nova pasta",
|
||||||
|
"prompt.new_folder": "Nome da pasta:",
|
||||||
|
"prompt.new_folder_default": "Nova pasta"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,5 +58,9 @@
|
|||||||
"update.up_to_date_body": "OmniMTP {version} — последний выпуск.",
|
"update.up_to_date_body": "OmniMTP {version} — последний выпуск.",
|
||||||
"update.check_failed_title": "Не удалось проверить обновления",
|
"update.check_failed_title": "Не удалось проверить обновления",
|
||||||
"update.check_failed_body": "Не удалось связаться с сервером обновлений.",
|
"update.check_failed_body": "Не удалось связаться с сервером обновлений.",
|
||||||
"update.http_error": "Сервер вернул HTTP {code}."
|
"update.http_error": "Сервер вернул HTTP {code}.",
|
||||||
|
"btn.new_folder": "Новая папка",
|
||||||
|
"ctx.new_folder": "Новая папка",
|
||||||
|
"prompt.new_folder": "Имя папки:",
|
||||||
|
"prompt.new_folder_default": "Новая папка"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,5 +58,9 @@
|
|||||||
"update.up_to_date_body": "OmniMTP {version} 已是最新版本。",
|
"update.up_to_date_body": "OmniMTP {version} 已是最新版本。",
|
||||||
"update.check_failed_title": "检查更新失败",
|
"update.check_failed_title": "检查更新失败",
|
||||||
"update.check_failed_body": "无法连接更新服务器。",
|
"update.check_failed_body": "无法连接更新服务器。",
|
||||||
"update.http_error": "服务器返回 HTTP {code}。"
|
"update.http_error": "服务器返回 HTTP {code}。",
|
||||||
|
"btn.new_folder": "新建文件夹",
|
||||||
|
"ctx.new_folder": "新建文件夹",
|
||||||
|
"prompt.new_folder": "文件夹名称:",
|
||||||
|
"prompt.new_folder_default": "新建文件夹"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user