fix: surface hash-duplicate warning and add manual refresh
Build / Check Links (push) Failing after 6s
Check Release / check_release (push) Failing after 1m1s
Build / build (push) Failing after 1m25s
Build / test_isolated (push) Skipped
Build / Integration tests (push) Skipped

Three issues fixed:

1. **Skipped-commit warning not visible** — When the user commits
   unchanged content (hash matches most recent version), the backend
   correctly returns {skipped: true, reason: "unchanged", message: ...}
   but the frontend Notification.warning was easy to miss (default
   4-5s autoClose, small toast at top-right). Now uses {autoClose: false}
   so the warning stays until manually dismissed, plus a console.log
   of the raw commit response for debugging. Added a fallback branch
   that warns clearly if the response shape is unexpected.

2. **No manual refresh on the panel** — In S3 setups the list call
   can occasionally lag the just-committed entry (manifest strong
   consistency is a per-implementation property). Added a "刷新"
   button at the top of the sidebar that calls model.refresh(), letting
   the user force a re-fetch when the auto-refresh misses.

3. **S3 addressing_style** — user-side config addition: S3Storage
   now uses path-style addressing (endpoint/bucket/key) which is the
   format required by MinIO and several other S3-compatible services.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
tao.chen
2026-07-21 18:23:34 +08:00
co-authored by Claude
parent cd1aba6698
commit bc1828bde6
4 changed files with 130 additions and 67 deletions
+4 -1
View File
@@ -82,7 +82,10 @@ class S3Storage(StorageAdapter):
aws_access_key_id=access_key, aws_access_key_id=access_key,
aws_secret_access_key=secret_key, aws_secret_access_key=secret_key,
region_name="us-east-1", region_name="us-east-1",
config=BotoConfig(signature_version="s3v4"), config=BotoConfig(
signature_version='s3v4',
s3={'addressing_style': 'path'},
),
) )
self._ClientError = ClientError self._ClientError = ClientError
self._connect_and_ensure_bucket() self._connect_and_ensure_bucket()
+19 -4
View File
@@ -64,12 +64,27 @@ export function registerCommands(
name, name,
description description
); );
if (result.skipped) { // eslint-disable-next-line no-console
// Content hash matches the most recent version — nothing to save. console.log('[snapshot] commit response:', result);
Notification.warning(result.message ?? '内容未变更,已跳过保存'); if (result && result.skipped) {
} else if (result.entry) { // Hash matched the most recent version — nothing to save. Use
// autoClose:false so the warning stays visible until the user
// dismisses it (the default 4-5s timeout is easy to miss).
Notification.warning(
result.message ?? '内容未变更,已跳过保存',
{ autoClose: false }
);
} else if (result && result.entry) {
await model.refresh(); await model.refresh();
Notification.success(`已保存快照:${name}`); Notification.success(`已保存快照:${name}`);
} else {
// Unexpected response shape — surface clearly so the user is not
// left wondering whether the save happened.
// eslint-disable-next-line no-console
console.warn('[snapshot] unexpected commit response shape:', result);
Notification.warning('保存响应格式异常,请查看浏览器控制台', {
autoClose: false
});
} }
} catch (e) { } catch (e) {
Notification.error(`保存快照失败:${(e as Error).message}`); Notification.error(`保存快照失败:${(e as Error).message}`);
+76 -62
View File
@@ -46,70 +46,84 @@ const Panel: React.FC<PanelProps> = ({ model, app, tracker, serverSettings, onOp
if (!model.path) { if (!model.path) {
return <div className="jp-snapshot-panel-empty"> notebook</div>; return <div className="jp-snapshot-panel-empty"> notebook</div>;
} }
if (model.loading && model.versions.length === 0) {
return <div className="jp-snapshot-panel-empty"></div>;
}
if (model.versions.length === 0) {
return (
<div className="jp-snapshot-panel-empty">
<div></div>
<div className="jp-snapshot-panel-hint"></div>
</div>
);
}
return ( return (
<ul className="jp-snapshot-panel-list"> <div className="jp-snapshot-panel-container">
{model.versions.map(v => ( <div className="jp-snapshot-panel-toolbar">
<li key={v.id} className="jp-snapshot-panel-item"> <button
<div className="jp-snapshot-panel-item-header"> className="jp-snapshot-panel-refresh-button"
<span className="jp-snapshot-panel-name" title={v.name}> onClick={() => {
{v.name} void model.refresh();
</span> }}
<span className="jp-snapshot-panel-size">{formatSize(v.size)}</span> disabled={model.loading}
title="重新拉取版本列表"
>
{model.loading ? '刷新中…' : '刷新'}
</button>
</div>
{model.loading && model.versions.length === 0 ? (
<div className="jp-snapshot-panel-empty"></div>
) : model.versions.length === 0 ? (
<div className="jp-snapshot-panel-empty">
<div></div>
<div className="jp-snapshot-panel-hint">
</div> </div>
{v.description ? ( </div>
<div className="jp-snapshot-panel-desc">{v.description}</div> ) : (
) : null} <ul className="jp-snapshot-panel-list">
<div className="jp-snapshot-panel-time">{formatTimestamp(v.timestamp)}</div> {model.versions.map(v => (
<div className="jp-snapshot-panel-actions"> <li key={v.id} className="jp-snapshot-panel-item">
<button <div className="jp-snapshot-panel-item-header">
className="jp-snapshot-panel-button" <span className="jp-snapshot-panel-name" title={v.name}>
onClick={async () => { {v.name}
const panel = tracker.currentWidget; </span>
if (!panel) { <span className="jp-snapshot-panel-size">{formatSize(v.size)}</span>
return; </div>
} {v.description ? (
const notebookModel = panel.content.model; <div className="jp-snapshot-panel-desc">{v.description}</div>
if (!notebookModel) { ) : null}
return; <div className="jp-snapshot-panel-time">{formatTimestamp(v.timestamp)}</div>
} <div className="jp-snapshot-panel-actions">
try { <button
const oldNb = (await getSnapshotContent( className="jp-snapshot-panel-button"
serverSettings, onClick={async () => {
model.path, const panel = tracker.currentWidget;
v.id if (!panel) {
)) as INotebook; return;
const newNb = notebookModel.toJSON() as unknown as INotebook; }
onOpenDiff(newNb, oldNb, v.name); const notebookModel = panel.content.model;
} catch (e) { if (!notebookModel) {
console.error('Diff failed', e); return;
} }
}} try {
> const oldNb = (await getSnapshotContent(
serverSettings,
</button> model.path,
<button v.id
className="jp-snapshot-panel-button" )) as INotebook;
onClick={() => { const newNb = notebookModel.toJSON() as unknown as INotebook;
void app.commands.execute(CommandIDs.restore, { id: v.id }); onOpenDiff(newNb, oldNb, v.name);
}} } catch (e) {
> console.error('Diff failed', e);
}
</button> }}
</div> >
</li>
))} </button>
</ul> <button
className="jp-snapshot-panel-button"
onClick={() => {
void app.commands.execute(CommandIDs.restore, { id: v.id });
}}
>
</button>
</div>
</li>
))}
</ul>
)}
</div>
); );
}; };
+31
View File
@@ -13,6 +13,37 @@
box-sizing: border-box; box-sizing: border-box;
} }
.jp-snapshot-panel-container {
display: flex;
flex-direction: column;
height: 100%;
}
.jp-snapshot-panel-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 6px;
}
.jp-snapshot-panel-refresh-button {
font-size: 0.8em;
padding: 2px 8px;
border: 1px solid var(--jp-border-color2, #ccc);
border-radius: 3px;
background: var(--jp-layout-color2, #f5f5f5);
color: var(--jp-ui-font-color1, #333);
cursor: pointer;
}
.jp-snapshot-panel-refresh-button:hover:not(:disabled) {
background: var(--jp-layout-color3, #e8e8e8);
}
.jp-snapshot-panel-refresh-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.jp-snapshot-panel-empty { .jp-snapshot-panel-empty {
color: var(--jp-ui-font-color2, #555); color: var(--jp-ui-font-color2, #555);
padding: 12px; padding: 12px;