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_secret_access_key=secret_key,
region_name="us-east-1",
config=BotoConfig(signature_version="s3v4"),
config=BotoConfig(
signature_version='s3v4',
s3={'addressing_style': 'path'},
),
)
self._ClientError = ClientError
self._connect_and_ensure_bucket()
+19 -4
View File
@@ -64,12 +64,27 @@ export function registerCommands(
name,
description
);
if (result.skipped) {
// Content hash matches the most recent version — nothing to save.
Notification.warning(result.message ?? '内容未变更,已跳过保存');
} else if (result.entry) {
// eslint-disable-next-line no-console
console.log('[snapshot] commit response:', result);
if (result && result.skipped) {
// 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();
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) {
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) {
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 (
<ul className="jp-snapshot-panel-list">
{model.versions.map(v => (
<li key={v.id} className="jp-snapshot-panel-item">
<div className="jp-snapshot-panel-item-header">
<span className="jp-snapshot-panel-name" title={v.name}>
{v.name}
</span>
<span className="jp-snapshot-panel-size">{formatSize(v.size)}</span>
<div className="jp-snapshot-panel-container">
<div className="jp-snapshot-panel-toolbar">
<button
className="jp-snapshot-panel-refresh-button"
onClick={() => {
void model.refresh();
}}
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>
{v.description ? (
<div className="jp-snapshot-panel-desc">{v.description}</div>
) : null}
<div className="jp-snapshot-panel-time">{formatTimestamp(v.timestamp)}</div>
<div className="jp-snapshot-panel-actions">
<button
className="jp-snapshot-panel-button"
onClick={async () => {
const panel = tracker.currentWidget;
if (!panel) {
return;
}
const notebookModel = panel.content.model;
if (!notebookModel) {
return;
}
try {
const oldNb = (await getSnapshotContent(
serverSettings,
model.path,
v.id
)) as INotebook;
const newNb = notebookModel.toJSON() as unknown as INotebook;
onOpenDiff(newNb, oldNb, v.name);
} catch (e) {
console.error('Diff failed', e);
}
}}
>
</button>
<button
className="jp-snapshot-panel-button"
onClick={() => {
void app.commands.execute(CommandIDs.restore, { id: v.id });
}}
>
</button>
</div>
</li>
))}
</ul>
</div>
) : (
<ul className="jp-snapshot-panel-list">
{model.versions.map(v => (
<li key={v.id} className="jp-snapshot-panel-item">
<div className="jp-snapshot-panel-item-header">
<span className="jp-snapshot-panel-name" title={v.name}>
{v.name}
</span>
<span className="jp-snapshot-panel-size">{formatSize(v.size)}</span>
</div>
{v.description ? (
<div className="jp-snapshot-panel-desc">{v.description}</div>
) : null}
<div className="jp-snapshot-panel-time">{formatTimestamp(v.timestamp)}</div>
<div className="jp-snapshot-panel-actions">
<button
className="jp-snapshot-panel-button"
onClick={async () => {
const panel = tracker.currentWidget;
if (!panel) {
return;
}
const notebookModel = panel.content.model;
if (!notebookModel) {
return;
}
try {
const oldNb = (await getSnapshotContent(
serverSettings,
model.path,
v.id
)) as INotebook;
const newNb = notebookModel.toJSON() as unknown as INotebook;
onOpenDiff(newNb, oldNb, v.name);
} catch (e) {
console.error('Diff failed', e);
}
}}
>
</button>
<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;
}
.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 {
color: var(--jp-ui-font-color2, #555);
padding: 12px;